Implement default calls port

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-04-29 14:00:05 +03:00
Unverified
parent 6d9066b0a6
commit 1c7dc9a8f7
5 changed files with 72 additions and 54 deletions
@@ -129,9 +129,9 @@
<string name="server_ip_label">IP или имя сервера</string> <string name="server_ip_label">IP или имя сервера</string>
<string name="server_ip_hint">192.168.1.10</string> <string name="server_ip_hint">192.168.1.10</string>
<string name="api_port_label">Порт</string> <string name="api_port_label">Порт</string>
<string name="api_port_hint">8301</string> <string name="api_port_hint"></string>
<string name="calls_port_label">Порт звонков</string> <string name="calls_port_label">Порт звонков</string>
<string name="calls_port_hint">8302</string> <string name="calls_port_hint"></string>
<string name="https_enabled">Защищённое соединение</string> <string name="https_enabled">Защищённое соединение</string>
<string name="server_config_https_hint">HTTPS. Отключайте только для HTTP без шифрования.</string> <string name="server_config_https_hint">HTTPS. Отключайте только для HTTP без шифрования.</string>
<string name="server_config_https_headline">HTTPS</string> <string name="server_config_https_headline">HTTPS</string>
@@ -156,9 +156,9 @@
<string name="server_ip_label">Server IP or hostname</string> <string name="server_ip_label">Server IP or hostname</string>
<string name="server_ip_hint">192.168.1.10</string> <string name="server_ip_hint">192.168.1.10</string>
<string name="api_port_label">Port</string> <string name="api_port_label">Port</string>
<string name="api_port_hint">8301</string> <string name="api_port_hint"></string>
<string name="calls_port_label">Calls port</string> <string name="calls_port_label">Calls port</string>
<string name="calls_port_hint">8302</string> <string name="calls_port_hint"></string>
<string name="https_enabled">Secure connection</string> <string name="https_enabled">Secure connection</string>
<string name="server_config_https_hint">Uses HTTPS. Turn off only for plain HTTP.</string> <string name="server_config_https_hint">Uses HTTPS. Turn off only for plain HTTP.</string>
<string name="server_config_https_headline">HTTPS</string> <string name="server_config_https_headline">HTTPS</string>
@@ -11,14 +11,19 @@ import kotlinx.serialization.json.Json
import ru.fromchat.api.DeviceSessionInfo import ru.fromchat.api.DeviceSessionInfo
import ru.fromchat.ui.Theme import ru.fromchat.ui.Theme
/** Default WebRTC / calls port when none is stored or the field is left empty in UI. */
const val DEFAULT_CALLS_PORT = 8303
/** /**
* Server configuration: [serverIp], HTTP(S) [apiPort], optional [callsPort] (WebRTC / HAProxy; null = calls disabled). First install defaults to fromchat.ru:443 with calls on 8302. * Server configuration: [serverIp], HTTP(S) [apiPort], [callsPort] for WebRTC / HAProxy (defaults to [DEFAULT_CALLS_PORT]).
* [callsEnabled] is updated when saving server settings (probe on the calls port); defaults to true when unset.
*/ */
data class ServerConfigData( data class ServerConfigData(
val serverIp: String, val serverIp: String,
val apiPort: Int, val apiPort: Int,
val callsPort: Int?, val callsPort: Int,
val httpsEnabled: Boolean, val httpsEnabled: Boolean,
val callsEnabled: Boolean = true,
) )
object Settings { object Settings {
@@ -28,8 +33,9 @@ object Settings {
private const val SERVER_URL_KEY = "server_url" private const val SERVER_URL_KEY = "server_url"
private const val SERVER_IP_KEY = "server_ip" private const val SERVER_IP_KEY = "server_ip"
private const val API_PORT_KEY = "api_port" private const val API_PORT_KEY = "api_port"
/** Stored as Int; values ≤ 0 mean unset (calls disabled). */ /** Stored as Int; invalid values fall back to [DEFAULT_CALLS_PORT] when read. */
private const val CALLS_PORT_KEY = "calls_port" private const val CALLS_PORT_KEY = "calls_port"
private const val CALLS_ENABLED_KEY = "calls_enabled"
private const val HTTPS_ENABLED_KEY = "https_enabled" private const val HTTPS_ENABLED_KEY = "https_enabled"
private const val DEVICE_SESSIONS_CACHE_KEY = "device_sessions_cache_v1" private const val DEVICE_SESSIONS_CACHE_KEY = "device_sessions_cache_v1"
private const val LAST_SERVER_INSTANCE_ID_KEY = "last_server_instance_id" private const val LAST_SERVER_INSTANCE_ID_KEY = "last_server_instance_id"
@@ -105,7 +111,7 @@ object Settings {
if (!settings.contains(SERVER_IP_KEY)) { if (!settings.contains(SERVER_IP_KEY)) {
settings.putString(SERVER_IP_KEY, "fromchat.ru") settings.putString(SERVER_IP_KEY, "fromchat.ru")
settings.putInt(API_PORT_KEY, 443) settings.putInt(API_PORT_KEY, 443)
settings.putInt(CALLS_PORT_KEY, 8302) settings.putInt(CALLS_PORT_KEY, DEFAULT_CALLS_PORT)
if (!settings.contains(HTTPS_ENABLED_KEY)) { if (!settings.contains(HTTPS_ENABLED_KEY)) {
settings.putBoolean(HTTPS_ENABLED_KEY, true) settings.putBoolean(HTTPS_ENABLED_KEY, true)
} }
@@ -116,21 +122,28 @@ object Settings {
} }
val apiPort = settings.getInt(API_PORT_KEY, 443).coerceIn(1, 65535) val apiPort = settings.getInt(API_PORT_KEY, 443).coerceIn(1, 65535)
val rawCalls = settings.getInt(CALLS_PORT_KEY, -1) val rawCalls = settings.getInt(CALLS_PORT_KEY, -1)
val calls = if (rawCalls > 0 && rawCalls <= 65535) rawCalls else null val callsPort = if (rawCalls in 1..65535) rawCalls else DEFAULT_CALLS_PORT
val https = settings.getBoolean(HTTPS_ENABLED_KEY, true) val https = settings.getBoolean(HTTPS_ENABLED_KEY, true)
val callsEnabled =
if (settings.contains(CALLS_ENABLED_KEY)) {
settings.getBoolean(CALLS_ENABLED_KEY, true)
} else {
true
}
ServerConfigData( ServerConfigData(
serverIp = ip, serverIp = ip,
apiPort = apiPort, apiPort = apiPort,
callsPort = calls, callsPort = callsPort,
httpsEnabled = https, httpsEnabled = https,
callsEnabled = callsEnabled,
) )
} }
set(value) = runIO { set(value) = runIO {
settings.putString(SERVER_IP_KEY, value.serverIp.trim()) settings.putString(SERVER_IP_KEY, value.serverIp.trim())
settings.putInt(API_PORT_KEY, value.apiPort.coerceIn(1, 65535)) settings.putInt(API_PORT_KEY, value.apiPort.coerceIn(1, 65535))
val callsStored = value.callsPort?.coerceIn(1, 65535) ?: -1 settings.putInt(CALLS_PORT_KEY, value.callsPort.coerceIn(1, 65535))
settings.putInt(CALLS_PORT_KEY, callsStored)
settings.putBoolean(HTTPS_ENABLED_KEY, value.httpsEnabled) settings.putBoolean(HTTPS_ENABLED_KEY, value.httpsEnabled)
settings.putBoolean(CALLS_ENABLED_KEY, value.callsEnabled)
} }
/** Cached device sessions (JSON). Shown immediately while refreshing from the network. */ /** Cached device sessions (JSON). Shown immediately while refreshing from the network. */
@@ -35,9 +35,9 @@ object Config {
_serverConfig.value = config _serverConfig.value = config
} }
/** Voice/video calls (LiveKit) are enabled only when a calls (WebRTC) port is configured. */ /** Voice/video calls when the last server-config apply probe to the calls port succeeded. */
val callsEnabled: Boolean val callsEnabled: Boolean
get() = config.callsPort != null get() = config.callsEnabled
/** /**
* LiveKit signaling WebSocket URL: same host and API port as HTTPS reverse proxy (`/api/livekit/rtc`). * LiveKit signaling WebSocket URL: same host and API port as HTTPS reverse proxy (`/api/livekit/rtc`).
@@ -107,6 +107,7 @@ import dev.chrisbanes.haze.rememberHazeState
import kotlin.time.TimeSource import kotlin.time.TimeSource
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
@@ -114,13 +115,12 @@ 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.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.api_port_hint
import ru.fromchat.api_port_label import ru.fromchat.api_port_label
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.calls_port_hint
import ru.fromchat.calls_port_label import ru.fromchat.calls_port_label
import ru.fromchat.cancel import ru.fromchat.cancel
import ru.fromchat.confirm import ru.fromchat.confirm
import ru.fromchat.core.DEFAULT_CALLS_PORT
import ru.fromchat.core.ServerConfigData import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.Settings import ru.fromchat.core.Settings
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
@@ -137,7 +137,6 @@ import ru.fromchat.server_config_snackbar_api_fail
import ru.fromchat.server_config_snackbar_defaults import ru.fromchat.server_config_snackbar_defaults
import ru.fromchat.server_config_snackbar_ok_api_calls_bad import ru.fromchat.server_config_snackbar_ok_api_calls_bad
import ru.fromchat.server_config_snackbar_ok_calls import ru.fromchat.server_config_snackbar_ok_calls
import ru.fromchat.server_config_snackbar_ok_calls_skip
import ru.fromchat.server_config_subtitle import ru.fromchat.server_config_subtitle
import ru.fromchat.server_config_title import ru.fromchat.server_config_title
import ru.fromchat.server_ip_hint import ru.fromchat.server_ip_hint
@@ -206,6 +205,13 @@ private fun apiBaseUrlFor(config: ServerConfigData): String {
return "$scheme://${config.serverIp}:${config.apiPort}/api" return "$scheme://${config.serverIp}:${config.apiPort}/api"
} }
private suspend fun probeCallsReachable(config: ServerConfigData): Boolean {
val urlScheme = if (config.httpsEnabled) "https" else "http"
val host = config.serverIp.trim()
val root = "$urlScheme://${hostForAuthority(host)}:${config.callsPort}/"
return ApiClient.probeHttpGet(root)
}
private fun resolvedApiPort(apiPortText: String): Int { private fun resolvedApiPort(apiPortText: String): Int {
val t = apiPortText.trim() val t = apiPortText.trim()
if (t.isEmpty()) return 443 if (t.isEmpty()) return 443
@@ -213,9 +219,12 @@ private fun resolvedApiPort(apiPortText: String): Int {
return n.takeIf { it in 1..65535 } ?: 443 return n.takeIf { it in 1..65535 } ?: 443
} }
private fun resolvedCallsPort(callsPortText: String): Int? = /** Blank calls field uses [DEFAULT_CALLS_PORT] instead of disabling calls. */
if (callsPortText.isBlank()) null private fun effectiveCallsPort(callsPortText: String): Int {
else callsPortText.trim().toIntOrNull()?.takeIf { it in 1..65535 } val t = callsPortText.trim()
if (t.isBlank()) return DEFAULT_CALLS_PORT
return t.toIntOrNull()?.takeIf { it in 1..65535 } ?: DEFAULT_CALLS_PORT
}
private suspend fun LazyListState.scrollFocusedItemIntoView( private suspend fun LazyListState.scrollFocusedItemIntoView(
itemIndex: Int, itemIndex: Int,
@@ -421,7 +430,7 @@ fun ServerConfigScreen() {
val c = Settings.serverConfig val c = Settings.serverConfig
serverIp = c.serverIp serverIp = c.serverIp
apiPortText = c.apiPort.toString() apiPortText = c.apiPort.toString()
callsPortText = c.callsPort?.toString().orEmpty() callsPortText = c.callsPort.toString()
httpsEnabled = c.httpsEnabled httpsEnabled = c.httpsEnabled
} }
@@ -447,7 +456,7 @@ fun ServerConfigScreen() {
val callsPortError = callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText) val callsPortError = callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText)
val apiPortEffective = resolvedApiPort(apiPortText) val apiPortEffective = resolvedApiPort(apiPortText)
val callsPortParsed = resolvedCallsPort(callsPortText) val callsPortParsed = effectiveCallsPort(callsPortText)
val canApply = val canApply =
hostOk && hostOk &&
!apiPortError && !apiPortError &&
@@ -457,7 +466,7 @@ fun ServerConfigScreen() {
val resetToDefaults = { val resetToDefaults = {
serverIp = "fromchat.ru" serverIp = "fromchat.ru"
apiPortText = "443" apiPortText = "443"
callsPortText = "8302" callsPortText = DEFAULT_CALLS_PORT.toString()
httpsEnabled = true httpsEnabled = true
scope.launch { scope.launch {
snackbarHostState.showSnackbar(strSnackbarDefaults) snackbarHostState.showSnackbar(strSnackbarDefaults)
@@ -466,6 +475,8 @@ fun ServerConfigScreen() {
val verifyServer: () -> Unit = { val verifyServer: () -> Unit = {
scope.launch { scope.launch {
// Do not block actual checks on snackbar dismissal.
launch { snackbarHostState.showSnackbar("Checking...") }
val host = serverIp.trim() val host = serverIp.trim()
if (host.isEmpty() || !isValidIpOrHostname(host)) { if (host.isEmpty() || !isValidIpOrHostname(host)) {
snackbarHostState.showSnackbar(strHostError) snackbarHostState.showSnackbar(strHostError)
@@ -480,7 +491,7 @@ fun ServerConfigScreen() {
return@launch return@launch
} }
val apiPort = resolvedApiPort(apiPortText) val apiPort = resolvedApiPort(apiPortText)
val calls = resolvedCallsPort(callsPortText) val calls = effectiveCallsPort(callsPortText)
val tentative = ServerConfigData( val tentative = ServerConfigData(
serverIp = host, serverIp = host,
apiPort = apiPort, apiPort = apiPort,
@@ -488,32 +499,24 @@ fun ServerConfigScreen() {
httpsEnabled = httpsEnabled, httpsEnabled = httpsEnabled,
) )
val apiBase = apiBaseUrlFor(tentative) val apiBase = apiBaseUrlFor(tentative)
val msg = runCatching {
withTimeout(3000) {
val pingMark = TimeSource.Monotonic.markNow() val pingMark = TimeSource.Monotonic.markNow()
val id = runCatching { ApiClient.fetchServerInstanceId(apiBase) } val id = ApiClient.fetchServerInstanceId(apiBase)
.getOrNull()
?.trim()
.orEmpty()
val pingMs = pingMark.elapsedNow().inWholeMilliseconds val pingMs = pingMark.elapsedNow().inWholeMilliseconds
.toInt() .toInt()
.coerceAtLeast(0) .coerceAtLeast(0)
if (id.isEmpty()) { if (id.isEmpty()) return@withTimeout strSnackbarApiFail
snackbarHostState.showSnackbar(strSnackbarApiFail)
return@launch
}
val urlScheme = if (httpsEnabled) "https" else "http" val urlScheme = if (httpsEnabled) "https" else "http"
val callsOk = if (calls != null) {
val root = "$urlScheme://${hostForAuthority(host)}:${calls}/" val root = "$urlScheme://${hostForAuthority(host)}:${calls}/"
ApiClient.probeHttpGet(root) val callsOk = ApiClient.probeHttpGet(root)
} else { if (callsOk) getString(Res.string.server_config_snackbar_ok_calls, pingMs)
null else strSnackbarOkApiCallsBad
}
val msg = when {
calls == null ->
getString(Res.string.server_config_snackbar_ok_calls_skip, pingMs)
callsOk == true ->
getString(Res.string.server_config_snackbar_ok_calls, pingMs)
else -> strSnackbarOkApiCallsBad
} }
}.getOrElse { strSnackbarApiFail }
snackbarHostState.showSnackbar(msg) snackbarHostState.showSnackbar(msg)
} }
} }
@@ -621,7 +624,8 @@ fun ServerConfigScreen() {
val bearer = ApiClient.token?.trim().orEmpty() val bearer = ApiClient.token?.trim().orEmpty()
if (bearer.isEmpty()) { if (bearer.isEmpty()) {
Config.updateServerConfig(tentative) val callsOk = probeCallsReachable(tentative)
Config.updateServerConfig(tentative.copy(callsEnabled = callsOk))
Settings.lastKnownServerInstanceId = newId Settings.lastKnownServerInstanceId = newId
WebSocketManager.disconnect() WebSocketManager.disconnect()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -646,7 +650,8 @@ fun ServerConfigScreen() {
return@launch return@launch
} }
Config.updateServerConfig(tentative) val callsOk = probeCallsReachable(tentative)
Config.updateServerConfig(tentative.copy(callsEnabled = callsOk))
Settings.lastKnownServerInstanceId = newId Settings.lastKnownServerInstanceId = newId
WebSocketManager.disconnect() WebSocketManager.disconnect()
WebSocketManager.connect(forceRestart = true) WebSocketManager.connect(forceRestart = true)
@@ -839,7 +844,7 @@ fun ServerConfigScreen() {
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
}, },
placeholder = { Text(stringResource(Res.string.api_port_hint)) }, placeholder = { Text("8301") },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { .onFocusChanged {
@@ -872,7 +877,7 @@ fun ServerConfigScreen() {
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
}, },
placeholder = { Text(stringResource(Res.string.calls_port_hint)) }, placeholder = { Text(DEFAULT_CALLS_PORT.toString()) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { .onFocusChanged {