From a633321f172e994c8146886e0ee680935ac0a665 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 15 Jul 2026 23:57:03 +0300 Subject: [PATCH] Simplify server config screen --- README.en.md | 168 ++++++------ README.md | 172 ++++++------ .../composeResources/values-ru/strings.xml | 14 +- .../composeResources/values/strings.xml | 16 +- .../kotlin/ru/fromchat/api/calls/CallStore.kt | 2 +- .../api/instance/ServerConfigProbe.kt | 131 ++++++++-- .../kotlin/ru/fromchat/config/ServerConfig.kt | 26 +- .../kotlin/ru/fromchat/config/Settings.kt | 4 +- .../ui/main/settings/server/HostValidation.kt | 20 +- .../settings/server/ServerConfigScreen.kt | 245 +++--------------- .../ui/main/settings/server/ServerEndpoint.kt | 79 ++++++ 11 files changed, 452 insertions(+), 425 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerEndpoint.kt 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 +}