Simplify server config screen

This commit is contained in:
2026-07-15 23:57:03 +03:00
Unverified
parent d92f518d87
commit a633321f17
11 changed files with 452 additions and 425 deletions
+92 -76
View File
@@ -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)
+94 -78
View File
@@ -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).
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)
@@ -228,8 +228,8 @@
<string name="month_name_dec">декабря</string>
<string name="server_config_title">Настройка сервера</string>
<string name="server_config_subtitle">Подключение к альтернативному серверу FromChat. Это дает больше приватности и контроля над данными.</string>
<string name="server_ip_label">IP или имя сервера</string>
<string name="server_ip_hint">192.168.1.10</string>
<string name="server_ip_label">Сервер</string>
<string name="server_ip_hint">api.fromchat.ru или host:порт</string>
<string name="api_port_label">Порт</string>
<string name="api_port_hint"></string>
<string name="calls_port_label">Порт звонков</string>
@@ -238,20 +238,20 @@
<string name="server_config_https_hint">HTTPS. Отключайте только для HTTP без шифрования.</string>
<string name="server_config_https_headline">HTTPS</string>
<string name="server_config_https_local_hint">Защищённое соединение. Если сервер локальный, скорее всего вам нужно это отключить.</string>
<string name="server_config_host_error">Укажите корректный IP или имя хоста.</string>
<string name="server_config_host_error">Укажите хост или host:порт (порт по умолчанию — 443).</string>
<string name="server_config_port_error">Порт от 1 до 65535.</string>
<string name="server_config_snackbar_ok_calls">Сервер доступен, пинг: %1$d мс.</string>
<string name="server_config_snackbar_ok_calls">Сервер доступен, звонки OK, пинг: %1$d мс.</string>
<string name="server_config_snackbar_ok_calls_skip">Сервер доступен (без звонков), пинг: %1$d мс.</string>
<string name="server_config_snackbar_api_fail">Не удалось подключиться к серверу.</string>
<string name="server_config_snackbar_calls_fail">Порт звонков недоступен.</string>
<string name="server_config_snackbar_ok_api_calls_bad">Сервер OK; порт звонков не ответил.</string>
<string name="server_config_snackbar_calls_fail">Звонки недоступны.</string>
<string name="server_config_snackbar_ok_api_calls_bad">Сервер OK; звонки не ответили.</string>
<string name="server_config_snackbar_defaults">Сброшено на значения по умолчанию.</string>
<string name="server_config_fab_verify">Проверить сервер</string>
<string name="server_config_fab_reset">Сброс</string>
<string name="server_config_action_check">Проверить</string>
<string name="server_config_action_reset">Сбросить</string>
<string name="server_config_action_reset_confirm_title">Сбросить настройки?</string>
<string name="server_config_action_reset_confirm_body">Будут восстановлены адрес сервера и порты по умолчанию.</string>
<string name="server_config_action_reset_confirm_body">Будет восстановлен адрес сервера по умолчанию.</string>
<string name="server_config_unsupported_no_instance_id">Сервер не предоставляет корректный ID экземпляра. Подключение невозможно.</string>
<string name="server_config_snackbar_timeout">Сервер не ответил вовремя. Повторите попытку.</string>
<string name="server_config_checking">Проверка…</string>
@@ -254,28 +254,28 @@
<!-- Server Configuration -->
<string name="server_config_title">Connect to a server</string>
<string name="server_config_subtitle">Connect to an alternative FromChat server. This can give you more privacy and control over data.</string>
<string name="server_ip_label">Server IP or hostname</string>
<string name="server_ip_hint">192.168.1.10</string>
<string name="server_ip_label">Server</string>
<string name="server_ip_hint">api.fromchat.ru or host:port</string>
<string name="api_port_label">Port</string>
<string name="calls_port_label">Calls port</string>
<string name="https_enabled">Secure connection</string>
<string name="server_config_https_hint">Uses HTTPS. Turn off only for plain HTTP.</string>
<string name="server_config_https_headline">HTTPS</string>
<string name="server_config_https_local_hint">Secure connection. If your server is local, you probably should turn it off.</string>
<string name="server_config_host_error">Enter a valid IP or hostname.</string>
<string name="server_config_host_error">Enter a valid host, or host:port (default port 443).</string>
<string name="server_config_port_error">Enter a port from 1 to 65535.</string>
<string name="server_config_snackbar_ok_calls">Server OK. Calls port reachable. Ping: %1$d ms.</string>
<string name="server_config_snackbar_ok_calls_skip">Server OK. Calls disabled (no port). Ping: %1$d ms.</string>
<string name="server_config_snackbar_ok_calls">Server OK. Calls reachable. Ping: %1$d ms.</string>
<string name="server_config_snackbar_ok_calls_skip">Server OK. Calls unavailable. Ping: %1$d ms.</string>
<string name="server_config_snackbar_api_fail">Cannot reach the API.</string>
<string name="server_config_snackbar_calls_fail">Calls port not reachable.</string>
<string name="server_config_snackbar_ok_api_calls_bad">Server OK; calls port did not respond.</string>
<string name="server_config_snackbar_calls_fail">Calls not reachable.</string>
<string name="server_config_snackbar_ok_api_calls_bad">Server OK; calls did not respond.</string>
<string name="server_config_snackbar_defaults">Defaults applied.</string>
<string name="server_config_fab_verify">Check server</string>
<string name="server_config_fab_reset">Reset to defaults</string>
<string name="server_config_action_check">Check</string>
<string name="server_config_action_reset">Reset</string>
<string name="server_config_action_reset_confirm_title">Reset to defaults?</string>
<string name="server_config_action_reset_confirm_body">This restores the default server and port settings.</string>
<string name="server_config_action_reset_confirm_body">This restores the default server address.</string>
<string name="server_config_unsupported_no_instance_id">This server does not provide a valid instance ID. Cannot connect.</string>
<string name="server_config_snackbar_timeout">Server did not respond in time. Try again.</string>
<string name="server_config_checking">Checking…</string>
@@ -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)")
@@ -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<ServerConfigData, ServerProbeResult> {
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<ServerProbeResult, Throwable?> {
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
@@ -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,
)
)
@@ -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)
}
@@ -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
}
}
@@ -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
@@ -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<String, Int>? {
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
}