diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index 7f44705..c92ed54 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -129,9 +129,9 @@
IP или имя сервера
192.168.1.10
Порт
- 8301
+
Порт звонков
- 8302
+
Защищённое соединение
HTTPS. Отключайте только для HTTP без шифрования.
HTTPS
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index abb65d5..db8f821 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -156,9 +156,9 @@
Server IP or hostname
192.168.1.10
Port
- 8301
+
Calls port
- 8302
+
Secure connection
Uses HTTPS. Turn off only for plain HTTP.
HTTPS
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt
index 22c26d0..9318e0c 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt
@@ -11,14 +11,19 @@ import kotlinx.serialization.json.Json
import ru.fromchat.api.DeviceSessionInfo
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(
val serverIp: String,
val apiPort: Int,
- val callsPort: Int?,
+ val callsPort: Int,
val httpsEnabled: Boolean,
+ val callsEnabled: Boolean = true,
)
object Settings {
@@ -28,8 +33,9 @@ object Settings {
private const val SERVER_URL_KEY = "server_url"
private const val SERVER_IP_KEY = "server_ip"
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_ENABLED_KEY = "calls_enabled"
private const val HTTPS_ENABLED_KEY = "https_enabled"
private const val DEVICE_SESSIONS_CACHE_KEY = "device_sessions_cache_v1"
private const val LAST_SERVER_INSTANCE_ID_KEY = "last_server_instance_id"
@@ -105,7 +111,7 @@ object Settings {
if (!settings.contains(SERVER_IP_KEY)) {
settings.putString(SERVER_IP_KEY, "fromchat.ru")
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)) {
settings.putBoolean(HTTPS_ENABLED_KEY, true)
}
@@ -116,21 +122,28 @@ object Settings {
}
val apiPort = settings.getInt(API_PORT_KEY, 443).coerceIn(1, 65535)
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 callsEnabled =
+ if (settings.contains(CALLS_ENABLED_KEY)) {
+ settings.getBoolean(CALLS_ENABLED_KEY, true)
+ } else {
+ true
+ }
ServerConfigData(
serverIp = ip,
apiPort = apiPort,
- callsPort = calls,
+ callsPort = callsPort,
httpsEnabled = https,
+ callsEnabled = callsEnabled,
)
}
set(value) = runIO {
settings.putString(SERVER_IP_KEY, value.serverIp.trim())
settings.putInt(API_PORT_KEY, value.apiPort.coerceIn(1, 65535))
- val callsStored = value.callsPort?.coerceIn(1, 65535) ?: -1
- settings.putInt(CALLS_PORT_KEY, callsStored)
+ settings.putInt(CALLS_PORT_KEY, value.callsPort.coerceIn(1, 65535))
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. */
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt
index d0b721d..24391a7 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt
@@ -35,9 +35,9 @@ object 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
- get() = config.callsPort != null
+ get() = config.callsEnabled
/**
* LiveKit signaling WebSocket URL: same host and API port as HTTPS reverse proxy (`/api/livekit/rtc`).
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt
index f514e72..2082e0d 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt
@@ -107,6 +107,7 @@ import dev.chrisbanes.haze.rememberHazeState
import kotlin.time.TimeSource
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.getString
@@ -114,13 +115,12 @@ import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager
-import ru.fromchat.api_port_hint
import ru.fromchat.api_port_label
import ru.fromchat.back
-import ru.fromchat.calls_port_hint
import ru.fromchat.calls_port_label
import ru.fromchat.cancel
import ru.fromchat.confirm
+import ru.fromchat.core.DEFAULT_CALLS_PORT
import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.Settings
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_ok_api_calls_bad
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_title
import ru.fromchat.server_ip_hint
@@ -206,6 +205,13 @@ private fun apiBaseUrlFor(config: ServerConfigData): String {
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 {
val t = apiPortText.trim()
if (t.isEmpty()) return 443
@@ -213,9 +219,12 @@ private fun resolvedApiPort(apiPortText: String): Int {
return n.takeIf { it in 1..65535 } ?: 443
}
-private fun resolvedCallsPort(callsPortText: String): Int? =
- if (callsPortText.isBlank()) null
- else callsPortText.trim().toIntOrNull()?.takeIf { it in 1..65535 }
+/** Blank calls field uses [DEFAULT_CALLS_PORT] instead of disabling calls. */
+private fun effectiveCallsPort(callsPortText: String): Int {
+ 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(
itemIndex: Int,
@@ -421,7 +430,7 @@ fun ServerConfigScreen() {
val c = Settings.serverConfig
serverIp = c.serverIp
apiPortText = c.apiPort.toString()
- callsPortText = c.callsPort?.toString().orEmpty()
+ callsPortText = c.callsPort.toString()
httpsEnabled = c.httpsEnabled
}
@@ -447,7 +456,7 @@ fun ServerConfigScreen() {
val callsPortError = callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText)
val apiPortEffective = resolvedApiPort(apiPortText)
- val callsPortParsed = resolvedCallsPort(callsPortText)
+ val callsPortParsed = effectiveCallsPort(callsPortText)
val canApply =
hostOk &&
!apiPortError &&
@@ -457,7 +466,7 @@ fun ServerConfigScreen() {
val resetToDefaults = {
serverIp = "fromchat.ru"
apiPortText = "443"
- callsPortText = "8302"
+ callsPortText = DEFAULT_CALLS_PORT.toString()
httpsEnabled = true
scope.launch {
snackbarHostState.showSnackbar(strSnackbarDefaults)
@@ -466,6 +475,8 @@ fun ServerConfigScreen() {
val verifyServer: () -> Unit = {
scope.launch {
+ // Do not block actual checks on snackbar dismissal.
+ launch { snackbarHostState.showSnackbar("Checking...") }
val host = serverIp.trim()
if (host.isEmpty() || !isValidIpOrHostname(host)) {
snackbarHostState.showSnackbar(strHostError)
@@ -480,7 +491,7 @@ fun ServerConfigScreen() {
return@launch
}
val apiPort = resolvedApiPort(apiPortText)
- val calls = resolvedCallsPort(callsPortText)
+ val calls = effectiveCallsPort(callsPortText)
val tentative = ServerConfigData(
serverIp = host,
apiPort = apiPort,
@@ -488,32 +499,24 @@ fun ServerConfigScreen() {
httpsEnabled = httpsEnabled,
)
val apiBase = apiBaseUrlFor(tentative)
- val pingMark = TimeSource.Monotonic.markNow()
- val id = runCatching { ApiClient.fetchServerInstanceId(apiBase) }
- .getOrNull()
- ?.trim()
- .orEmpty()
- val pingMs = pingMark.elapsedNow().inWholeMilliseconds
- .toInt()
- .coerceAtLeast(0)
- if (id.isEmpty()) {
- snackbarHostState.showSnackbar(strSnackbarApiFail)
- return@launch
- }
- val urlScheme = if (httpsEnabled) "https" else "http"
- val callsOk = if (calls != null) {
- val root = "$urlScheme://${hostForAuthority(host)}:${calls}/"
- ApiClient.probeHttpGet(root)
- } else {
- null
- }
- 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
- }
+
+ val msg = runCatching {
+ withTimeout(3000) {
+ val pingMark = TimeSource.Monotonic.markNow()
+ val id = ApiClient.fetchServerInstanceId(apiBase)
+ val pingMs = pingMark.elapsedNow().inWholeMilliseconds
+ .toInt()
+ .coerceAtLeast(0)
+ if (id.isEmpty()) return@withTimeout strSnackbarApiFail
+
+ val urlScheme = if (httpsEnabled) "https" else "http"
+ val root = "$urlScheme://${hostForAuthority(host)}:${calls}/"
+ val callsOk = ApiClient.probeHttpGet(root)
+ if (callsOk) getString(Res.string.server_config_snackbar_ok_calls, pingMs)
+ else strSnackbarOkApiCallsBad
+ }
+ }.getOrElse { strSnackbarApiFail }
+
snackbarHostState.showSnackbar(msg)
}
}
@@ -621,7 +624,8 @@ fun ServerConfigScreen() {
val bearer = ApiClient.token?.trim().orEmpty()
if (bearer.isEmpty()) {
- Config.updateServerConfig(tentative)
+ val callsOk = probeCallsReachable(tentative)
+ Config.updateServerConfig(tentative.copy(callsEnabled = callsOk))
Settings.lastKnownServerInstanceId = newId
WebSocketManager.disconnect()
withContext(Dispatchers.Main) {
@@ -646,7 +650,8 @@ fun ServerConfigScreen() {
return@launch
}
- Config.updateServerConfig(tentative)
+ val callsOk = probeCallsReachable(tentative)
+ Config.updateServerConfig(tentative.copy(callsEnabled = callsOk))
Settings.lastKnownServerInstanceId = newId
WebSocketManager.disconnect()
WebSocketManager.connect(forceRestart = true)
@@ -839,7 +844,7 @@ fun ServerConfigScreen() {
overflow = TextOverflow.Ellipsis,
)
},
- placeholder = { Text(stringResource(Res.string.api_port_hint)) },
+ placeholder = { Text("8301") },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged {
@@ -872,7 +877,7 @@ fun ServerConfigScreen() {
overflow = TextOverflow.Ellipsis,
)
},
- placeholder = { Text(stringResource(Res.string.calls_port_hint)) },
+ placeholder = { Text(DEFAULT_CALLS_PORT.toString()) },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged {