diff --git a/README.en.md b/README.en.md
index 969a5bd..73f67b6 100644
--- a/README.en.md
+++ b/README.en.md
@@ -2,120 +2,136 @@ Read in other languages: [Русский](./README.md)
# FromChat
-FromChat is a 100% free and open-source messenger. This repository contains the cross-platform client for Android and iOS.
+FromChat is a 100% free and open-source messenger. This repository is the cross-platform client (Android + iOS; iOS is not ready yet).
-[📥 Download](https://github.com/fromchat-messenger/app/releases/latest) • [💬 Telegram Channel](https://t.me/fromchat_ch) • [🖥️ Server](https://github.com/fromchat-messenger/backend)
+[📥 Download](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram Channel](https://t.me/fromchat_ch) • [🖥️ Server](https://github.com/fromchat-messenger/backend)
## ✨ Features
-- **Voice and video calls** — high-quality communication via LiveKit
-- **Screen sharing** — share your screen during calls
-- **Group chat** — community of all server users
-- **Direct messages** — uses legal encryption scheme. Server-side E2EE mode planned.
-- **Device management** — control active sessions
-- **Dark mode by default** — stylish and easy on the eyes
-- **Open source** — full transparency
+- **Voice and video calls** — LiveKit
+- **Screen sharing** during calls
+- **Public chat** — server-wide community
+- **Direct messages** — legal encryption scheme; server-side E2EE planned
+- **Device management** — active sessions
+- **Dark mode** by default
+- **Open source**
## 📊 Client Comparison
-⚠️ **iOS is temporarily not supported.** iOS development requires a lot of time and due to Apple's restrictions it is much more complex than Android. Therefore it will be released later.
-
-This table shows which features are implemented in the official clients.
-
+⚠️ **iOS is temporarily not supported** (Apple constraints and development cost). It will ship later.
| Feature | Android | Web | iOS |
| --- | --- | --- | --- |
-| **Messaging and profiles** | ✅ | ✅ | ✅ |
-| **Voice/video calls** | ✅ | ❌ | ❌ |
-| **Screen sharing** | ✅ | ❌ | ❌ |
+| **Messaging and profiles** | ✅ | ✅ | ❌ |
+| **Voice/video calls** | ✅ | ✅ | ❌ |
+| **Screen sharing** | ✅ | ✅ | ❌ |
| **Message reactions** | ❌ | ✅ | ❌ |
| **Rich attachment support** | ✅ | ❌ | ❌ |
-
---
## 🏗️ Tech Stack
-- **Kotlin** — a programming language focused on safety and performance
-- **Compose Multiplatform** — declarative UI framework for cross-platform development
-- **Material Design 3** — modern user interface components
-- **Ktor Client** — asynchronous HTTP client for network requests
-- **LiveKit** — infrastructure for video calls and conferences
-- **SQLDelight** — type-safe SQL queries for local data storage
-- **Firebase Messaging** — push notifications
-- **Coil** — image loading and caching
+- **Kotlin**
+- **Compose Multiplatform**
+- **Material Design 3**
+- **Ktor Client**
+- **LiveKit** — calls
+- **SQLDelight** — local storage
+- **Firebase Messaging** — push
+- **Coil** — images
+---
## 📥 Build and Development (Android Studio)
### Requirements
-- Latest version of Android Studio
-- Components updated to the latest versions
+- Latest Android Studio
+- JDK from Android Studio (JetBrains Runtime)
-### Quick Start
+### Quick start
1. **Clone the repository:**
- ```bash
- git clone https://github.com/fromchat-messenger/app.git
- cd app
- ```
+
+ ```bash
+ git clone https://github.com/fromchat-messenger/android.git
+ cd android
+ ```
+
2. **Generate keys (Debug & Release):**
- 1. Set variables (replace `CHANGEME` with your secure passwords):
- ```bash
- DEBUG_STORE_PASS=CHANGEME
- DEBUG_KEY_PASS=CHANGEME
- RELEASE_STORE_PASS=CHANGEME
- RELEASE_KEY_PASS=CHANGEME
- ```
- 2. Run commands:
- ```bash
- mkdir -p app/android/keys
- keytool -genkey -v -keystore app/android/keys/debug.jks \
- -keyalg RSA -keysize 2048 -validity 10000 \
- -alias key0 -storepass $DEBUG_STORE_PASS -keypass $DEBUG_KEY_PASS \
- -dname "CN=Debug, O=FromChat, C=RU"
+ ```bash
+ DEBUG_STORE_PASS=CHANGEME
+ DEBUG_KEY_PASS=CHANGEME
+ RELEASE_STORE_PASS=CHANGEME
+ RELEASE_KEY_PASS=CHANGEME
- keytool -genkey -v -keystore app/android/keys/release.jks \
- -keyalg RSA -keysize 2048 -validity 10000 \
- -alias key0 -storepass $RELEASE_STORE_PASS -keypass $RELEASE_KEY_PASS \
- -dname "CN=Release, O=FromChat, C=RU"
+ mkdir -p app/android/keys
- cat > app/android/keystore.properties << EOF
- releaseStorePassword=$RELEASE_STORE_PASS
- releaseKeyPassword=$RELEASE_KEY_PASS
- debugStorePassword=$DEBUG_STORE_PASS
- debugKeyPassword=$DEBUG_KEY_PASS
- EOF
- ```
-3. **Open in Android Studio:**
- - Select: `File → Open → app/`
- - Android Studio automatically loads all dependencies and syncs Gradle
-4. **Run:**
- - Click `Run → Run 'Android'` (Shift+F10)
- - Select an emulator or connected device
+ keytool -genkey -v -keystore app/android/keys/debug.jks \
+ -keyalg RSA -keysize 2048 -validity 10000 \
+ -alias key0 -storepass $DEBUG_STORE_PASS -keypass $DEBUG_KEY_PASS \
+ -dname "CN=Debug, O=FromChat, C=RU"
-### Project Structure
+ keytool -genkey -v -keystore app/android/keys/release.jks \
+ -keyalg RSA -keysize 2048 -validity 10000 \
+ -alias key0 -storepass $RELEASE_STORE_PASS -keypass $RELEASE_KEY_PASS \
+ -dname "CN=Release, O=FromChat, C=RU"
+
+ cat > app/android/keystore.properties << EOF
+ releaseStorePassword=$RELEASE_STORE_PASS
+ releaseKeyPassword=$RELEASE_KEY_PASS
+ debugStorePassword=$DEBUG_STORE_PASS
+ debugKeyPassword=$DEBUG_KEY_PASS
+ EOF
+ ```
+
+3. **Open in Android Studio:** `File → Open` → repo root. Gradle syncs dependencies automatically.
+
+4. **Run:** `Run → Run 'Android'` (or the app debug configuration).
+ Debug application id: `ru.fromchat.beta`.
+
+### CLI build
+
+```bash
+export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
+./gradlew :app:shared:compileAndroidMain :app:android:assembleDebug
+```
+
+APK: `app/android/build/outputs/apk/debug/android-debug.apk`.
+
+### Project structure
```
android/
-├── app/ # Client code
-│ ├── android/ # Android app module
-│ └── shared/ # Shared Compose code (Android + iOS)
-│ ├── commonMain/ # Cross-platform code
-│ ├── androidMain/ # Android-specific code
-│ └── iosMain/ # iOS-specific code (not ready yet)
-├── utils/ # Module with useful stuff that can be used in other projects
-│ ├── android/ # Android library module
-│ └── shared/
-└── gradle/libs.versions.toml # Dependency management
+├── app/
+│ ├── android/ # Android app module
+│ └── shared/ # Compose Multiplatform
+│ ├── commonMain/
+│ ├── androidMain/
+│ └── iosMain/ # not ready yet
+├── utils/ # reusable utilities
+│ ├── android/
+│ └── shared/
+└── gradle/libs.versions.toml
```
+Code style: [CODE_STYLE.md](./CODE_STYLE.md).
+
+---
+
## 🤝 Contribute
-If you want to support the project development, just send a Pull Request. I'm sure you already know that, I'm too lazy to write it all out.
+Pull requests are welcome. For large changes, follow `CODE_STYLE.md`.
## 📄 License
-This project is licensed under the GNU Affero General Public License v3.0. For details, see the [LICENSE](./LICENSE) file.
+GNU Affero General Public License v3.0 — see [LICENSE](./LICENSE).
+
+## 🔗 Related Repositories
+
+- [Backend](https://github.com/fromchat-messenger/backend)
+- [Web](https://github.com/fromchat-messenger/web)
+- [Website](https://github.com/fromchat-messenger/site)
+- [Deployment](https://github.com/fromchat-messenger/deployment)
diff --git a/README.md b/README.md
index fade9b6..0b1eb25 100644
--- a/README.md
+++ b/README.md
@@ -2,120 +2,136 @@
# FromChat
-FromChat — 100% бесплатный и открытый мессенджер. В этом репозитории содержится кроссплатформенный клиент для Android и iOS.
+FromChat — 100% бесплатный и открытый мессенджер. В этом репозитории — кроссплатформенный клиент (Android + iOS; iOS пока не готов).
-[📥 Скачать](https://github.com/fromchat-messenger/app/releases/latest) • [💬 Telegram-канал](https://t.me/fromchat_ch) • [🖥️ Сервер](https://github.com/fromchat-messenger/backend)
+[📥 Скачать](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram-канал](https://t.me/fromchat_ch) • [🖥️ Сервер](https://github.com/fromchat-messenger/backend)
## ✨ Возможности
-- **Голосовые и видеозвонки** — высокое качество связи через LiveKit
-- **Совместное использование экрана** — демонстрация экрана при вызовах
-- **Общий чат** — сообщество всех пользователей на сервере
-- **Личные сообщения** — используется легальная схема шифрования. Планируется режим E2EE на сервере.
-- **Управление устройствами** — контроль активных сеансов
-- **Тёмный режим по умолчанию** — стильный и приятный для глаз вид
-- **Открытый исходный код** — полная прозрачность
+- **Голосовые и видеозвонки** — LiveKit
+- **Демонстрация экрана** во время звонков
+- **Общий чат** — сообщество пользователей сервера
+- **Личные сообщения** — легальная схема шифрования; E2EE на сервере планируется
+- **Управление устройствами** — активные сеансы
+- **Тёмный режим** по умолчанию
+- **Открытый исходный код**
## 📊 Сравнение клиентов
-⚠️ **iOS временно не поддерживается.** Разработка iOS-части требует много времени и из-за ограничений Apple это гораздо сложнее, чем на Android. Поэтому она выйдет позже.
-
-В этой таблице показано, какие возможности реализованы в официальных клиентах.
-
-
-| Возможность | Android | Web | iOS |
-| ----------------------------------- | ------- | --- | --- |
-| **Обмен сообщениями и профили** | ✅ | ✅ | ✅ |
-| **Голосовые/видеозвонки** | ✅ | ❌ | ❌ |
-| **Совместное использование экрана** | ✅ | ❌ | ❌ |
-| **Реакции на сообщения** | ❌ | ✅ | ❌ |
-| **Расширенная поддержка вложений** | ✅ | ❌ | ❌ |
+⚠️ **iOS временно не поддерживается** (ограничения Apple и объём работы). Клиент выйдет позже.
+| Возможность | Android | Web | iOS |
+| --- | --- | --- | --- |
+| **Обмен сообщениями и профили** | ✅ | ✅ | ❌ |
+| **Голосовые/видеозвонки** | ✅ | ✅ | ❌ |
+| **Демонстрация экрана** | ✅ | ✅ | ❌ |
+| **Реакции на сообщения** | ❌ | ✅ | ❌ |
+| **Расширенная поддержка вложений** | ✅ | ❌ | ❌ |
---
## 🏗️ Технологический стек
-- **Kotlin** — язык программирования, ориентированный на безопасность и производительность
-- **Compose Multiplatform** — объявленный UI фреймворк для кроссплатформенной разработки
-- **Material Design 3** — современные компоненты пользовательского интерфейса
-- **Ktor Client** — асинхронный HTTP клиент для сетевых запросов
-- **LiveKit** — инфраструктура для видеозвонков и конференций
-- **SQLDelight** — тип-безопасные SQL запросы для локального хранилища данных
-- **Firebase Messaging** — push-уведомления
-- **Coil** — загрузка и кэширование изображений
+- **Kotlin**
+- **Compose Multiplatform**
+- **Material Design 3**
+- **Ktor Client**
+- **LiveKit** — звонки
+- **SQLDelight** — локальное хранилище
+- **Firebase Messaging** — push
+- **Coil** — изображения
+---
## 📥 Сборка и разработка (Android Studio)
### Требования
-- Последняя версия Android Studio
-- Компоненты, обновленные до последних версий
+- Актуальная Android Studio
+- JDK из Android Studio (JetBrains Runtime)
### Быстрый старт
1. **Клонируйте репозиторий:**
- ```bash
- git clone https://github.com/fromchat-messenger/app.git
- cd app
- ```
+
+ ```bash
+ git clone https://github.com/fromchat-messenger/android.git
+ cd android
+ ```
+
2. **Сгенерируйте ключи (Debug & Release):**
- 1. Установите переменные (замените `CHANGEME` на ваши надежные пароли):
- ```bash
- DEBUG_STORE_PASS=CHANGEME
- DEBUG_KEY_PASS=CHANGEME
- RELEASE_STORE_PASS=CHANGEME
- RELEASE_KEY_PASS=CHANGEME
- ```
- 2. Выполните команды:
- ```bash
- mkdir -p app/android/keys
- keytool -genkey -v -keystore app/android/keys/debug.jks \
- -keyalg RSA -keysize 2048 -validity 10000 \
- -alias key0 -storepass $DEBUG_STORE_PASS -keypass $DEBUG_KEY_PASS \
- -dname "CN=Debug, O=FromChat, C=RU"
+ ```bash
+ DEBUG_STORE_PASS=CHANGEME
+ DEBUG_KEY_PASS=CHANGEME
+ RELEASE_STORE_PASS=CHANGEME
+ RELEASE_KEY_PASS=CHANGEME
- keytool -genkey -v -keystore app/android/keys/release.jks \
- -keyalg RSA -keysize 2048 -validity 10000 \
- -alias key0 -storepass $RELEASE_STORE_PASS -keypass $RELEASE_KEY_PASS \
- -dname "CN=Release, O=FromChat, C=RU"
+ mkdir -p app/android/keys
- cat > app/android/keystore.properties << EOF
- releaseStorePassword=$RELEASE_STORE_PASS
- releaseKeyPassword=$RELEASE_KEY_PASS
- debugStorePassword=$DEBUG_STORE_PASS
- debugKeyPassword=$DEBUG_KEY_PASS
- EOF
- ```
-3. **Откройте в Android Studio:**
- - Выберите: `File → Open → app/`
- - Android Studio автоматически загружает все зависимости и синхронизирует Gradle
-4. **Запустите:**
- - Нажмите `Run → Run 'Android'` (Shift+F10)
- - Выберите эмулятор или подключённое устройство
+ keytool -genkey -v -keystore app/android/keys/debug.jks \
+ -keyalg RSA -keysize 2048 -validity 10000 \
+ -alias key0 -storepass $DEBUG_STORE_PASS -keypass $DEBUG_KEY_PASS \
+ -dname "CN=Debug, O=FromChat, C=RU"
+
+ keytool -genkey -v -keystore app/android/keys/release.jks \
+ -keyalg RSA -keysize 2048 -validity 10000 \
+ -alias key0 -storepass $RELEASE_STORE_PASS -keypass $RELEASE_KEY_PASS \
+ -dname "CN=Release, O=FromChat, C=RU"
+
+ cat > app/android/keystore.properties << EOF
+ releaseStorePassword=$RELEASE_STORE_PASS
+ releaseKeyPassword=$RELEASE_KEY_PASS
+ debugStorePassword=$DEBUG_STORE_PASS
+ debugKeyPassword=$DEBUG_KEY_PASS
+ EOF
+ ```
+
+3. **Откройте в Android Studio:** `File → Open` → корень репозитория. Gradle подтянет зависимости сам.
+
+4. **Запустите:** `Run → Run 'Android'` (или debug-конфигурацию приложения).
+ Debug application id: `ru.fromchat.beta`.
+
+### Сборка из CLI
+
+```bash
+export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
+./gradlew :app:shared:compileAndroidMain :app:android:assembleDebug
+```
+
+APK: `app/android/build/outputs/apk/debug/android-debug.apk`.
### Структура проекта
```
android/
-├── app/ # Код клиента
-│ ├── android/ # Модуль Android-приложения
-│ └── shared/ # Общий код Compose (Android + iOS)
-│ ├── commonMain/ # Кроссплатформенный код
-│ ├── androidMain/ # Android-специфичный код
-│ └── iosMain/ # iOS-специфичный код (еще не готово)
-├── utils/ # Модуль всяких полезных штук, которые можно использовать в другом проекте
-│ ├── android/ # Модуль Android-библиотеки
-│ └── shared/
-└── gradle/libs.versions.toml # Управление зависимостями
+├── app/
+│ ├── android/ # Android app module
+│ └── shared/ # Compose Multiplatform
+│ ├── commonMain/
+│ ├── androidMain/
+│ └── iosMain/ # ещё не готово
+├── utils/ # переиспользуемые утилиты
+│ ├── android/
+│ └── shared/
+└── gradle/libs.versions.toml
```
+Стиль кода: [CODE_STYLE.md](./CODE_STYLE.md).
+
+---
+
## 🤝 Внести вклад
-Если вам хочется поддержать разработку проекта, просто кидайте Pull Request, я уверен, что вы это уже знаете, мне лень это расписывать.
+Pull Request приветствуются. Перед крупными изменениями загляните в `CODE_STYLE.md`.
## 📄 Лицензия
-Этот проект лицензирован в соответствии с лицензией GNU Affero General Public License v3.0. Подробности см. в файле [LICENSE](./LICENSE).
\ No newline at end of file
+GNU Affero General Public License v3.0 — см. [LICENSE](./LICENSE).
+
+## 🔗 Связанные репозитории
+
+- [Backend](https://github.com/fromchat-messenger/backend)
+- [Web](https://github.com/fromchat-messenger/web)
+- [Website](https://github.com/fromchat-messenger/site)
+- [Deployment](https://github.com/fromchat-messenger/deployment)
diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index b37de16..111c4a7 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -228,8 +228,8 @@
декабря
Настройка сервера
Подключение к альтернативному серверу FromChat. Это дает больше приватности и контроля над данными.
- IP или имя сервера
- 192.168.1.10
+ Сервер
+ api.fromchat.ru или host:порт
Порт
Порт звонков
@@ -238,20 +238,20 @@
HTTPS. Отключайте только для HTTP без шифрования.
HTTPS
Защищённое соединение. Если сервер локальный, скорее всего вам нужно это отключить.
- Укажите корректный IP или имя хоста.
+ Укажите хост или host:порт (порт по умолчанию — 443).
Порт от 1 до 65535.
- Сервер доступен, пинг: %1$d мс.
+ Сервер доступен, звонки OK, пинг: %1$d мс.
Сервер доступен (без звонков), пинг: %1$d мс.
Не удалось подключиться к серверу.
- Порт звонков недоступен.
- Сервер OK; порт звонков не ответил.
+ Звонки недоступны.
+ Сервер OK; звонки не ответили.
Сброшено на значения по умолчанию.
Проверить сервер
Сброс
Проверить
Сбросить
Сбросить настройки?
- Будут восстановлены адрес сервера и порты по умолчанию.
+ Будет восстановлен адрес сервера по умолчанию.
Сервер не предоставляет корректный ID экземпляра. Подключение невозможно.
Сервер не ответил вовремя. Повторите попытку.
Проверка…
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index 7c620c1..f1f7ab7 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -254,28 +254,28 @@
Connect to a server
Connect to an alternative FromChat server. This can give you more privacy and control over data.
- Server IP or hostname
- 192.168.1.10
+ Server
+ api.fromchat.ru or host:port
Port
Calls port
Secure connection
Uses HTTPS. Turn off only for plain HTTP.
HTTPS
Secure connection. If your server is local, you probably should turn it off.
- Enter a valid IP or hostname.
+ Enter a valid host, or host:port (default port 443).
Enter a port from 1 to 65535.
- Server OK. Calls port reachable. Ping: %1$d ms.
- Server OK. Calls disabled (no port). Ping: %1$d ms.
+ Server OK. Calls reachable. Ping: %1$d ms.
+ Server OK. Calls unavailable. Ping: %1$d ms.
Cannot reach the API.
- Calls port not reachable.
- Server OK; calls port did not respond.
+ Calls not reachable.
+ Server OK; calls did not respond.
Defaults applied.
Check server
Reset to defaults
Check
Reset
Reset to defaults?
- This restores the default server and port settings.
+ This restores the default server address.
This server does not provide a valid instance ID. Cannot connect.
Server did not respond in time. Try again.
Checking…
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt
index 2a65d67..22d1ea7 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt
@@ -120,7 +120,7 @@ object CallStore {
fun startOutgoingCall(peerUserId: Int) {
if (peerUserId <= 0 || peerUserId == ApiClient.user?.id) return
if (!ServerConfig.callsEnabled) {
- Logger.d(TAG, "startOutgoingCall ignored (calls disabled: set calls port in server settings)")
+ Logger.d(TAG, "startOutgoingCall ignored (calls disabled)")
return
}
Logger.i(TAG, "startOutgoingCall(peer=$peerUserId)")
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt
index a3d1968..f4b53b4 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt
@@ -22,26 +22,110 @@ sealed interface ServerProbeResult {
data object Unreachable : ServerProbeResult
}
-private const val CALLS_PROBE_MS = 1_500L
-
-suspend fun probeCallsReachable(config: ServerConfigData): Boolean {
- val urlScheme = if (config.httpsEnabled) "https" else "http"
- val host = config.serverIp.trim()
- val authorityHost = host.removePrefix("[").removeSuffix("]").ifEmpty { host }
- val root = "$urlScheme://$authorityHost:${config.callsPort}/"
- return runCatching {
- withTimeout(CALLS_PROBE_MS) {
- ApiClient.probeHttpGet(root)
- }
- }.getOrDefault(false)
-}
-
sealed interface ApplyServerResult {
data object Applied : ApplyServerResult
data object ServerUnreachable : ApplyServerResult
}
-suspend fun probeServer(config: ServerConfigData): ServerProbeResult {
+private const val CALLS_PROBE_MS = 1_500L
+
+/** True when [error] looks like a TLS/SSL handshake failure (safe to fall back to HTTP). */
+fun Throwable.isSslProtocolError(): Boolean {
+ var cur: Throwable? = this
+ while (cur != null) {
+ val name = cur::class.simpleName.orEmpty()
+ val msg = cur.message.orEmpty()
+ if (
+ name.contains("SSL", ignoreCase = true) ||
+ name.contains("TLS", ignoreCase = true) ||
+ name.contains("Cert", ignoreCase = true) ||
+ msg.contains("SSL", ignoreCase = true) ||
+ msg.contains("TLS", ignoreCase = true) ||
+ msg.contains("certificate", ignoreCase = true) ||
+ msg.contains("handshake", ignoreCase = true)
+ ) {
+ return true
+ }
+ cur = cur.cause
+ }
+ return false
+}
+
+suspend fun probeCallsReachable(config: ServerConfigData): Boolean {
+ val urlScheme = if (config.httpsEnabled) "https" else "http"
+ val host = config.serverIp.trim()
+ val authorityHost = host.removePrefix("[").removeSuffix("]").ifEmpty { host }
+ // Caddy rewrites this to LiveKit "/" (plain "OK"). GET /rtc is always 404 without a WS upgrade.
+ val url = "$urlScheme://$authorityHost:${config.apiPort}/livekit-health"
+ return runCatching {
+ withTimeout(CALLS_PROBE_MS) {
+ ApiClient.probeHttpGet(url)
+ }
+ }.getOrDefault(false)
+}
+
+suspend fun probeServer(config: ServerConfigData): ServerProbeResult =
+ probeServerEndpoint(
+ host = config.serverIp,
+ port = config.apiPort,
+ httpsPreferred = config.httpsEnabled,
+ schemeExplicit = true,
+ ).second
+
+/**
+ * Builds configs to try: explicit scheme only, or HTTPS then HTTP on SSL errors.
+ * Returns the working config (httpsEnabled set to what succeeded) and the probe result.
+ */
+suspend fun probeServerEndpoint(
+ host: String,
+ port: Int,
+ httpsPreferred: Boolean,
+ schemeExplicit: Boolean,
+): Pair {
+ val schemes = when {
+ schemeExplicit -> listOf(httpsPreferred)
+ else -> listOf(true, false)
+ }
+ var lastConfig = ServerConfigData(
+ serverIp = host,
+ apiPort = port,
+ callsPort = port,
+ httpsEnabled = httpsPreferred,
+ )
+ var lastResult: ServerProbeResult = ServerProbeResult.Unreachable
+ for ((index, https) in schemes.withIndex()) {
+ val config = ServerConfigData(
+ serverIp = host,
+ apiPort = port,
+ callsPort = port,
+ httpsEnabled = https,
+ )
+ lastConfig = config
+ val (result, failure) = probeServerOnce(config)
+ lastResult = result
+ when (result) {
+ is ServerProbeResult.Supported,
+ ServerProbeResult.Unsupported,
+ -> return config to result
+ ServerProbeResult.Timeout,
+ ServerProbeResult.Unreachable,
+ -> {
+ val hasHttpFallback = !schemeExplicit && https && index < schemes.lastIndex
+ if (!hasHttpFallback) return config to result
+ // Browser-like: only fall back to HTTP after a TLS/protocol failure.
+ if (failure?.isSslProtocolError() == true) continue
+ if (result is ServerProbeResult.Timeout) return config to result
+ // Non-SSL Unreachable (e.g. LAN cleartext / connection refused on 443): try HTTP.
+ continue
+ }
+ }
+ }
+ return lastConfig to lastResult
+}
+
+private suspend fun probeServerOnce(
+ config: ServerConfigData,
+): Pair {
val apiBase = apiBaseUrlFor(config)
val mark = TimeSource.Monotonic.markNow()
InstanceIdGuard.probeConfig = config
@@ -57,12 +141,19 @@ suspend fun probeServer(config: ServerConfigData): ServerProbeResult {
is InstanceIdResolveResult.Cached -> resolve.instanceId
is InstanceIdResolveResult.Fetched -> resolve.instanceId
is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId
- InstanceIdResolveResult.Unsupported -> return ServerProbeResult.Unsupported
- InstanceIdResolveResult.Timeout -> return ServerProbeResult.Timeout
- InstanceIdResolveResult.Unreachable -> return ServerProbeResult.Unreachable
+ InstanceIdResolveResult.Unsupported ->
+ return ServerProbeResult.Unsupported to null
+ InstanceIdResolveResult.Timeout ->
+ return ServerProbeResult.Timeout to null
+ InstanceIdResolveResult.Unreachable -> {
+ val failure = runCatching {
+ ApiClient.fetchServerInstanceId(apiBase)
+ }.exceptionOrNull()
+ return ServerProbeResult.Unreachable to failure
+ }
}
val callsOk = probeCallsReachable(config)
- return ServerProbeResult.Supported(instanceId, callsOk, pingMs)
+ return ServerProbeResult.Supported(instanceId, callsOk, pingMs) to null
} finally {
InstanceIdGuard.probeConfig = null
}
@@ -73,7 +164,7 @@ suspend fun applyServerConfig(
instanceId: String,
callsOk: Boolean,
) {
- val tentative = config.copy(callsEnabled = callsOk)
+ val tentative = config.copy(callsEnabled = callsOk, callsPort = config.apiPort)
ServerConfig.updateServerConfig(tentative)
DocumentRepository.invalidate()
val userId = ApiClient.user?.id
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/config/ServerConfig.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/config/ServerConfig.kt
index cb990b3..240e21b 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/config/ServerConfig.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/config/ServerConfig.kt
@@ -30,28 +30,24 @@ object ServerConfig {
_serverConfig.value = config
}
- /** Voice/video calls when the last server-config apply probe to the calls port succeeded. */
+ /** Voice/video calls when the last server-config apply probe succeeded. */
val callsEnabled: Boolean
get() = config.callsEnabled
- /** Calls port used for LiveKit WS / reachability (defaults to [DEFAULT_CALLS_PORT]). */
+ /** Port used for LiveKit WS (same as API; TLS reverse-proxy fronts the SFU). */
val callsPort: Int
- get() = config.callsPort
+ get() = config.apiPort
/**
- * LiveKit WS endpoint derived from the active server config:
- * - host = [ServerConfigData.serverIp]
- * - port = [ServerConfigData.callsPort]
- * - scheme = ws or wss based on [ServerConfigData.httpsEnabled]
+ * LiveKit WS URL: same host/port as the API (Caddy proxies `/rtc*` to the SFU over TLS).
*/
fun liveKitWsUrl(): String {
val scheme = if (config.httpsEnabled) "wss" else "ws"
- return "$scheme://${config.serverIp}:${config.callsPort}"
+ return "$scheme://${config.serverIp}:${config.apiPort}"
}
/**
- * LiveKit signaling WebSocket URL: same host and API port as HTTPS reverse proxy (`/livekit/rtc`).
- * WebRTC media uses the configured calls port on the server (e.g. HAProxy in front of LiveKit).
+ * LiveKit signaling WebSocket URL via the API reverse proxy (`/livekit/rtc`).
*/
fun liveKitSignalingWsUrl(): String {
val scheme = if (config.httpsEnabled) "wss" else "ws"
@@ -77,12 +73,14 @@ object ServerConfig {
}
}
-/** Default WebRTC / calls port when none is stored or the field is left empty in UI. */
+/** Legacy default when migrating old configs that stored a separate calls port. */
const val DEFAULT_CALLS_PORT = 8303
/**
- * 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.
+ * Server configuration: [serverIp] + [apiPort] (from a single `host[:port]` field; default port 443).
+ * [httpsEnabled] is resolved by probing (HTTPS first, HTTP on SSL errors) unless the user typed a scheme.
+ * [callsEnabled] is set when LiveKit is reachable through the API host (TLS proxy or same port).
+ * [callsPort] is kept for persistence compatibility and mirrors [apiPort].
*/
data class ServerConfigData(
val serverIp: String,
@@ -90,4 +88,4 @@ data class ServerConfigData(
val callsPort: Int,
val httpsEnabled: Boolean,
val callsEnabled: Boolean = true,
-)
\ No newline at end of file
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/config/Settings.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/config/Settings.kt
index 453b601..3bd85a7 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/config/Settings.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/config/Settings.kt
@@ -108,9 +108,9 @@ object Settings {
suspend fun readServerConfig(): ServerConfigData {
migrateLegacyServerUrlIfNeeded()
if (!settings.contains(SERVER_IP_KEY)) {
- settings.putString(SERVER_IP_KEY, "fromchat.ru")
+ settings.putString(SERVER_IP_KEY, "api.fromchat.ru")
settings.putInt(API_PORT_KEY, 443)
- settings.putInt(CALLS_PORT_KEY, DEFAULT_CALLS_PORT)
+ settings.putInt(CALLS_PORT_KEY, 443)
if (!settings.contains(HTTPS_ENABLED_KEY)) {
settings.putBoolean(HTTPS_ENABLED_KEY, true)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/HostValidation.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/HostValidation.kt
index 2d54f4a..6b6103c 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/HostValidation.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/HostValidation.kt
@@ -1,19 +1,21 @@
package ru.fromchat.ui.main.settings.server
-private fun isAllowedHostChar(ch: Char) = ch.isLetterOrDigit() || ch in ".:-[]_"
+private fun isAllowedHostChar(ch: Char) = ch.isLetterOrDigit() || ch in ".:-[]_/"
-internal fun filterHostInput(raw: String) = raw.filter(::isAllowedHostChar).take(253)
+internal fun filterHostInput(raw: String) = raw.filter(::isAllowedHostChar).take(280)
internal fun isValidPortNumber(text: String): Boolean =
(text.ifBlank { return true }.toIntOrNull() ?: return false) in 1..65535
+/** Host only (no scheme/port). Used after [parseServerEndpoint]. */
internal fun isValidIpOrHostname(host: String) = host.trim().let { h ->
!(
h.isEmpty() ||
h.length > 253 ||
- h.any { !isAllowedHostChar(it) }
+ h.any { !(it.isLetterOrDigit() || it in ".:-[]_") }
) && (
isValidIpv4(h) ||
+ isValidIpv6(h) ||
isValidIpv6Bracketed(h) ||
isValidHostname(h)
)
@@ -28,16 +30,18 @@ private fun isValidIpv4(s: String): Boolean {
return true
}
+private fun isValidIpv6(s: String): Boolean {
+ if (s.isBlank() || !s.all { it.isDigit() || it in "abcdefABCDEF:" }) return false
+ return s.count { it == ':' } >= 2
+}
+
private fun isValidIpv6Bracketed(s: String): Boolean {
if (!s.startsWith('[') || !s.contains(']')) return false
val end = s.indexOf(']')
if (end <= 1) return false
- val inner = s.substring(1, end)
- if (inner.isBlank() || !inner.all { it.isDigit() || it in "abcdefABCDEF:" }) return false
-
- return inner.count { it == ':' } >= 2
+ return isValidIpv6(s.substring(1, end))
}
private fun isValidHostname(host: String): Boolean {
@@ -78,4 +82,4 @@ internal fun hostForAuthority(host: String) =
!it.startsWith("[")
) "[$it]"
else it
- }
\ No newline at end of file
+ }
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt
index 5cbb106..a9bdd42 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt
@@ -25,12 +25,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.FlashOn
-import androidx.compose.material.icons.filled.Http
import androidx.compose.material.icons.filled.RestartAlt
-import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
@@ -61,18 +58,13 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.geometry.Rect
-import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
-import com.pr0gramm3r101.components.Category
-import com.pr0gramm3r101.components.SwitchListItem
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.toDp
import dev.chrisbanes.haze.HazeProgressive
@@ -89,18 +81,15 @@ import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.WebSocketManager
-import ru.fromchat.api_port_label
import ru.fromchat.back
-import ru.fromchat.calls_port_label
import ru.fromchat.cancel
import ru.fromchat.confirm
-import ru.fromchat.config.DEFAULT_CALLS_PORT
import ru.fromchat.config.ServerConfigData
import ru.fromchat.config.Settings
import ru.fromchat.api.instance.ServerProbeResult
import ru.fromchat.api.instance.ApplyServerResult
import ru.fromchat.api.instance.applyServerAndNavigate
-import ru.fromchat.api.instance.probeServer
+import ru.fromchat.api.instance.probeServerEndpoint
import ru.fromchat.save_continue
import ru.fromchat.server_config_action_check
import ru.fromchat.server_config_action_reset
@@ -108,9 +97,6 @@ import ru.fromchat.server_config_action_reset_confirm_body
import ru.fromchat.server_config_action_reset_confirm_title
import ru.fromchat.server_config_checking
import ru.fromchat.server_config_host_error
-import ru.fromchat.server_config_https_headline
-import ru.fromchat.server_config_https_local_hint
-import ru.fromchat.server_config_port_error
import ru.fromchat.server_config_snackbar_api_fail
import ru.fromchat.server_config_snackbar_defaults
import ru.fromchat.server_config_snackbar_ok_calls
@@ -133,22 +119,11 @@ import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape
import ru.fromchat.ui.components.rememberLazyListFocusScrollState
import ru.fromchat.ui.components.trackLazyListFocus
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
-import kotlin.math.roundToInt
private object ServerConfigLazyListIndices {
const val SERVER_IP_FIELD = 2
- const val PORT_FIELDS = 4
}
-private inline fun port(text: String, default: Int) = text
- .trim()
- .ifEmpty { default }
- .let { (it as String).toIntOrNull() }
- ?.takeIf { it in 1..65535 } ?: default
-
-private fun resolvedApiPort(apiPortText: String) = port(apiPortText, 443)
-private fun resolvedCallsPort(callsPortText: String) = port(callsPortText, DEFAULT_CALLS_PORT)
-
@OptIn(
ExperimentalFoundationApi::class,
ExperimentalMaterial3Api::class,
@@ -164,17 +139,11 @@ fun ServerConfigScreen() {
val snackbarHostState = remember { SnackbarHostState() }
val density = LocalDensity.current
- var serverIp by remember { mutableStateOf("") }
- var apiPortText by remember { mutableStateOf("") }
- var callsPortText by remember { mutableStateOf("") }
- var httpsEnabled by remember { mutableStateOf(true) }
+ var serverEndpoint by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
Settings.serverConfig.apply {
- serverIp = this.serverIp
- apiPortText = this.apiPort.toString()
- callsPortText = this.callsPort.toString()
- httpsEnabled = this.httpsEnabled
+ serverEndpoint = formatServerEndpointForInput(serverIp, apiPort)
}
}
@@ -188,7 +157,6 @@ fun ServerConfigScreen() {
val strSnackbarDefaults = stringResource(Res.string.server_config_snackbar_defaults)
val strHostError = stringResource(Res.string.server_config_host_error)
- val strPortError = stringResource(Res.string.server_config_port_error)
val strSnackbarApiFail = stringResource(Res.string.server_config_snackbar_api_fail)
val strSnackbarTimeout = stringResource(Res.string.server_config_snackbar_timeout)
val strUnsupportedInstance = stringResource(Res.string.server_config_unsupported_no_instance_id)
@@ -196,60 +164,35 @@ fun ServerConfigScreen() {
val strResetConfirmTitle = stringResource(Res.string.server_config_action_reset_confirm_title)
val strResetConfirmBody = stringResource(Res.string.server_config_action_reset_confirm_body)
- val hostOk = serverIp.isNotBlank() && isValidIpOrHostname(serverIp)
- val apiPortError = apiPortText.isNotEmpty() && !isValidPortNumber(apiPortText)
- val callsPortError = callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText)
+ val parsedEndpoint = remember(serverEndpoint) { parseServerEndpoint(serverEndpoint) }
+ val hostOk = parsedEndpoint != null
- val canApply =
- hostOk &&
- !apiPortError &&
- !callsPortError &&
- !busy
+ val canApply = hostOk && !busy
fun resetToDefaults() {
- serverIp = "fromchat.ru"
- apiPortText = "443"
- callsPortText = DEFAULT_CALLS_PORT.toString()
- httpsEnabled = true
+ serverEndpoint = "api.fromchat.ru"
scope.launch {
snackbarHostState.showSnackbar(strSnackbarDefaults)
}
}
- fun buildTentativeConfig(): ServerConfigData? {
- val host = serverIp.trim()
-
- if (
- host.isEmpty() ||
- !isValidIpOrHostname(host) ||
- (apiPortText.isNotEmpty() && !isValidPortNumber(apiPortText)) ||
- (callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText))
- ) return null
-
- return ServerConfigData(
- serverIp = host,
- apiPort = resolvedApiPort(apiPortText),
- callsPort = resolvedCallsPort(callsPortText),
- httpsEnabled = httpsEnabled,
- )
- }
+ fun buildTentativeFromParsed(): ParsedServerEndpoint? = parsedEndpoint
fun verifyServer() {
scope.launch {
launch { snackbarHostState.showSnackbar(strChecking) }
- val tentative = buildTentativeConfig()
- if (tentative == null) {
- snackbarHostState.showSnackbar(
- if (serverIp.isNotEmpty() && !isValidIpOrHostname(serverIp.trim())) {
- strHostError
- } else {
- strPortError
- },
- )
+ val parsed = buildTentativeFromParsed()
+ if (parsed == null) {
+ snackbarHostState.showSnackbar(strHostError)
return@launch
}
- val probe = probeServer(tentative)
+ val (tentative, probe) = probeServerEndpoint(
+ host = parsed.host,
+ port = parsed.port,
+ httpsPreferred = parsed.httpsPreferred,
+ schemeExplicit = parsed.schemeExplicit,
+ )
lastProbe = probe
lastProbedConfig = tentative
val msg = when (probe) {
@@ -305,11 +248,19 @@ fun ServerConfigScreen() {
try {
busy = true
- val tentative = buildTentativeConfig() ?: return@launch
- val probe = probeServer(tentative).also {
- lastProbe = it
- lastProbedConfig = tentative
+ val parsed = buildTentativeFromParsed()
+ if (parsed == null) {
+ snackbarHostState.showSnackbar(strHostError)
+ return@launch
}
+ val (tentative, probe) = probeServerEndpoint(
+ host = parsed.host,
+ port = parsed.port,
+ httpsPreferred = parsed.httpsPreferred,
+ schemeExplicit = parsed.schemeExplicit,
+ )
+ lastProbe = probe
+ lastProbedConfig = tentative
when (probe) {
ServerProbeResult.Unsupported -> {
@@ -487,8 +438,8 @@ fun ServerConfigScreen() {
item {
OutlinedTextField(
- value = serverIp,
- onValueChange = { serverIp = filterHostInput(it) },
+ value = serverEndpoint,
+ onValueChange = { serverEndpoint = filterHostInput(it) },
label = { Text(stringResource(Res.string.server_ip_label)) },
placeholder = { Text(stringResource(Res.string.server_ip_hint)) },
modifier = Modifier
@@ -499,15 +450,15 @@ fun ServerConfigScreen() {
)
.padding(horizontal = SettingsStepHorizontalPadding),
singleLine = true,
- isError = !hostOk,
- supportingText = if (!hostOk) {{
+ isError = serverEndpoint.isNotBlank() && !hostOk,
+ supportingText = if (serverEndpoint.isNotBlank() && !hostOk) {{
Text(stringResource(Res.string.server_config_host_error))
}} else null,
colors = fieldColors,
shape = SettingsPasswordOutlineFieldShape,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
- imeAction = ImeAction.Next,
+ imeAction = ImeAction.Done,
),
leadingIcon = {
Icon(Icons.Filled.Dns, null)
@@ -515,136 +466,8 @@ fun ServerConfigScreen() {
)
}
- item { Spacer(Modifier.height(8.dp)) }
-
- item {
- Layout(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = SettingsStepHorizontalPadding),
- content = {
- OutlinedTextField(
- value = apiPortText,
- onValueChange = { apiPortText = it.filter { it.isDigit() }.take(6) },
- label = {
- Text(
- text = stringResource(Res.string.api_port_label),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- },
- placeholder = { Text("8300") },
- modifier = Modifier
- .fillMaxWidth()
- .trackLazyListFocus(
- focusScrollState,
- ServerConfigLazyListIndices.PORT_FIELDS,
- ),
- singleLine = true,
- isError = apiPortError,
- supportingText = if (apiPortError) {
- { Text(stringResource(Res.string.server_config_port_error)) }
- } else null,
- colors = fieldColors,
- shape = SettingsPasswordOutlineFieldShape,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.NumberPassword,
- imeAction = ImeAction.Next,
- ),
- leadingIcon = {
- Icon(Icons.Filled.Http, null)
- },
- )
-
- OutlinedTextField(
- value = callsPortText,
- onValueChange = { callsPortText = it.filter { it.isDigit() }.take(6) },
- label = {
- Text(
- text = stringResource(Res.string.calls_port_label),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- },
- placeholder = { Text(DEFAULT_CALLS_PORT.toString()) },
- modifier = Modifier
- .fillMaxWidth()
- .trackLazyListFocus(
- focusScrollState,
- ServerConfigLazyListIndices.PORT_FIELDS,
- ),
- singleLine = true,
- isError = callsPortError,
- supportingText = if (callsPortError) {{
- Text(stringResource(Res.string.server_config_port_error))
- }} else null,
- colors = fieldColors,
- shape = SettingsPasswordOutlineFieldShape,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Number,
- imeAction = ImeAction.Done,
- ),
- leadingIcon = {
- Icon(Icons.Filled.Call, null)
- }
- )
- }
- ) { measurables, constraints ->
- val maxWidth = constraints.maxWidth
- val gapPx = 12.dp.roundToPx()
- val available = (maxWidth - gapPx).coerceAtLeast(0)
-
- val callsPx = (available * 0.60f)
- .roundToInt()
- .coerceAtLeast(168.dp.roundToPx())
- .coerceAtMost(available)
- val apiPx = (available - callsPx).coerceAtLeast(0)
-
- if (measurables.size != 2) {
- return@Layout layout(0, 0) {}
- }
-
- val apiWidth = measurables[0].measure(Constraints.fixedWidth(apiPx))
- val callsWidth = measurables[1].measure(Constraints.fixedWidth(callsPx))
-
- layout(
- maxWidth,
- maxOf(apiWidth.height, callsWidth.height)
- .coerceIn(constraints.minHeight, constraints.maxHeight)
- ) {
- apiWidth.place(0, 0)
- callsWidth.place(apiPx + gapPx, 0)
- }
- }
- }
-
item { Spacer(Modifier.height(16.dp)) }
- item {
- Column {
- Category(
- modifier = Modifier.fillMaxWidth(),
- margin = PaddingValues(
- start = SettingsStepHorizontalPadding,
- end = SettingsStepHorizontalPadding,
- ),
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
- ) {
- SwitchListItem(
- modifier = Modifier.fillMaxWidth(),
- headline = stringResource(Res.string.server_config_https_headline),
- supportingText = stringResource(Res.string.server_config_https_local_hint),
- leadingContent = {
- Icon(Icons.Filled.Shield, null)
- },
- checked = httpsEnabled,
- onCheckedChange = { httpsEnabled = it },
- divider = false,
- )
- }
- }
- }
-
item {
Row(
modifier = Modifier
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerEndpoint.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerEndpoint.kt
new file mode 100644
index 0000000..6ca7702
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerEndpoint.kt
@@ -0,0 +1,79 @@
+package ru.fromchat.ui.main.settings.server
+
+/**
+ * Parsed server endpoint from a single text field: `[scheme://]host[:port]`.
+ * Default port is 443 when omitted; scheme may be locked when typed explicitly.
+ */
+data class ParsedServerEndpoint(
+ val host: String,
+ val port: Int,
+ val httpsPreferred: Boolean,
+ val schemeExplicit: Boolean,
+)
+
+/**
+ * Parses `https://host:port`, `host:port`, `[ipv6]:port`, or bare host (port defaults to 443).
+ */
+fun parseServerEndpoint(raw: String): ParsedServerEndpoint? {
+ var t = raw.trim()
+ if (t.isEmpty()) return null
+
+ var schemeExplicit = false
+ var httpsPreferred = true
+ val lower = t.lowercase()
+ when {
+ lower.startsWith("https://") -> {
+ schemeExplicit = true
+ httpsPreferred = true
+ t = t.substring("https://".length)
+ }
+ lower.startsWith("http://") -> {
+ schemeExplicit = true
+ httpsPreferred = false
+ t = t.substring("http://".length)
+ }
+ }
+ t = t.trim().trimEnd('/')
+ if (t.isEmpty()) return null
+
+ val (host, port) = parseHostAndPort(t) ?: return null
+ if (!isValidIpOrHostname(host)) return null
+ return ParsedServerEndpoint(
+ host = host,
+ port = port,
+ httpsPreferred = httpsPreferred,
+ schemeExplicit = schemeExplicit,
+ )
+}
+
+/**
+ * Formats stored config for the single endpoint field (no scheme; port omitted when 443).
+ */
+fun formatServerEndpointForInput(host: String, port: Int): String {
+ val authority = hostForAuthority(host)
+ return if (port == 443) authority else "$authority:$port"
+}
+
+private fun parseHostAndPort(authority: String): Pair? {
+ if (authority.startsWith("[")) {
+ val end = authority.indexOf(']')
+ if (end <= 1) return null
+ val host = authority.substring(1, end)
+ val rest = authority.substring(end + 1)
+ if (rest.isEmpty()) return host to 443
+ if (!rest.startsWith(":")) return null
+ val port = rest.removePrefix(":").toIntOrNull()?.takeIf { it in 1..65535 } ?: return null
+ return host to port
+ }
+ val colon = authority.lastIndexOf(':')
+ if (colon > 0) {
+ val portPart = authority.substring(colon + 1)
+ if (portPart.isNotEmpty() && portPart.all { it.isDigit() }) {
+ val port = portPart.toIntOrNull()?.takeIf { it in 1..65535 } ?: return null
+ val host = authority.substring(0, colon)
+ if (host.isEmpty()) return null
+ return host to port
+ }
+ }
+ return authority to 443
+}