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
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 ## ✨ Features
- **Voice and video calls** — high-quality communication via LiveKit - **Voice and video calls** — LiveKit
- **Screen sharing** — share your screen during calls - **Screen sharing** during calls
- **Group chat** — community of all server users - **Public chat** — server-wide community
- **Direct messages** — uses legal encryption scheme. Server-side E2EE mode planned. - **Direct messages** — legal encryption scheme; server-side E2EE planned
- **Device management** — control active sessions - **Device management** — active sessions
- **Dark mode by default** — stylish and easy on the eyes - **Dark mode** by default
- **Open source** — full transparency - **Open source**
## 📊 Client Comparison ## 📊 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. ⚠️ **iOS is temporarily not supported** (Apple constraints and development cost). It will ship later.
This table shows which features are implemented in the official clients.
| Feature | Android | Web | iOS | | Feature | Android | Web | iOS |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| **Messaging and profiles** | ✅ | ✅ | | | **Messaging and profiles** | ✅ | ✅ | |
| **Voice/video calls** | ✅ | | ❌ | | **Voice/video calls** | ✅ | | ❌ |
| **Screen sharing** | ✅ | | ❌ | | **Screen sharing** | ✅ | | ❌ |
| **Message reactions** | ❌ | ✅ | ❌ | | **Message reactions** | ❌ | ✅ | ❌ |
| **Rich attachment support** | ✅ | ❌ | ❌ | | **Rich attachment support** | ✅ | ❌ | ❌ |
--- ---
## 🏗️ Tech Stack ## 🏗️ Tech Stack
- **Kotlin** — a programming language focused on safety and performance - **Kotlin**
- **Compose Multiplatform** — declarative UI framework for cross-platform development - **Compose Multiplatform**
- **Material Design 3** — modern user interface components - **Material Design 3**
- **Ktor Client** — asynchronous HTTP client for network requests - **Ktor Client**
- **LiveKit** — infrastructure for video calls and conferences - **LiveKit** — calls
- **SQLDelight** — type-safe SQL queries for local data storage - **SQLDelight** — local storage
- **Firebase Messaging** — push notifications - **Firebase Messaging** — push
- **Coil** — image loading and caching - **Coil** — images
---
## 📥 Build and Development (Android Studio) ## 📥 Build and Development (Android Studio)
### Requirements ### Requirements
- Latest version of Android Studio - Latest Android Studio
- Components updated to the latest versions - JDK from Android Studio (JetBrains Runtime)
### Quick Start ### Quick start
1. **Clone the repository:** 1. **Clone the repository:**
```bash
git clone https://github.com/fromchat-messenger/app.git ```bash
cd app git clone https://github.com/fromchat-messenger/android.git
``` cd android
```
2. **Generate keys (Debug & Release):** 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 \ ```bash
-keyalg RSA -keysize 2048 -validity 10000 \ DEBUG_STORE_PASS=CHANGEME
-alias key0 -storepass $DEBUG_STORE_PASS -keypass $DEBUG_KEY_PASS \ DEBUG_KEY_PASS=CHANGEME
-dname "CN=Debug, O=FromChat, C=RU" RELEASE_STORE_PASS=CHANGEME
RELEASE_KEY_PASS=CHANGEME
keytool -genkey -v -keystore app/android/keys/release.jks \ mkdir -p app/android/keys
-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 keytool -genkey -v -keystore app/android/keys/debug.jks \
releaseStorePassword=$RELEASE_STORE_PASS -keyalg RSA -keysize 2048 -validity 10000 \
releaseKeyPassword=$RELEASE_KEY_PASS -alias key0 -storepass $DEBUG_STORE_PASS -keypass $DEBUG_KEY_PASS \
debugStorePassword=$DEBUG_STORE_PASS -dname "CN=Debug, O=FromChat, C=RU"
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
### 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/ android/
├── app/ # Client code ├── app/
│ ├── android/ # Android app module │ ├── android/ # Android app module
│ └── shared/ # Shared Compose code (Android + iOS) │ └── shared/ # Compose Multiplatform
│ ├── commonMain/ # Cross-platform code │ ├── commonMain/
│ ├── androidMain/ # Android-specific code │ ├── androidMain/
│ └── iosMain/ # iOS-specific code (not ready yet) │ └── iosMain/ # not ready yet
├── utils/ # Module with useful stuff that can be used in other projects ├── utils/ # reusable utilities
│ ├── android/ # Android library module │ ├── android/
│ └── shared/ │ └── shared/
└── gradle/libs.versions.toml # Dependency management └── gradle/libs.versions.toml
``` ```
Code style: [CODE_STYLE.md](./CODE_STYLE.md).
---
## 🤝 Contribute ## 🤝 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 ## 📄 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
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 - **Голосовые и видеозвонки** — LiveKit
- **Совместное использование экрана** — демонстрация экрана при вызовах - **Демонстрация экрана** во время звонков
- **Общий чат** — сообщество всех пользователей на сервере - **Общий чат** — сообщество пользователей сервера
- **Личные сообщения** — используется легальная схема шифрования. Планируется режим E2EE на сервере. - **Личные сообщения** — легальная схема шифрования; E2EE на сервере планируется
- **Управление устройствами** — контроль активных сеансов - **Управление устройствами** — активные сеансы
- **Тёмный режим по умолчанию** — стильный и приятный для глаз вид - **Тёмный режим** по умолчанию
- **Открытый исходный код** — полная прозрачность - **Открытый исходный код**
## 📊 Сравнение клиентов ## 📊 Сравнение клиентов
⚠️ **iOS временно не поддерживается.** Разработка iOS-части требует много времени и из-за ограничений Apple это гораздо сложнее, чем на Android. Поэтому она выйдет позже. ⚠️ **iOS временно не поддерживается** (ограничения Apple и объём работы). Клиент выйдет позже.
В этой таблице показано, какие возможности реализованы в официальных клиентах.
| Возможность | Android | Web | iOS |
| ----------------------------------- | ------- | --- | --- |
| **Обмен сообщениями и профили** | ✅ | ✅ | ✅ |
| **Голосовые/видеозвонки** | ✅ | ❌ | ❌ |
| **Совместное использование экрана** | ✅ | ❌ | ❌ |
| **Реакции на сообщения** | ❌ | ✅ | ❌ |
| **Расширенная поддержка вложений** | ✅ | ❌ | ❌ |
| Возможность | Android | Web | iOS |
| --- | --- | --- | --- |
| **Обмен сообщениями и профили** | ✅ | ✅ | ❌ |
| **Голосовые/видеозвонки** | ✅ | ✅ | ❌ |
| **Демонстрация экрана** | ✅ | ✅ | ❌ |
| **Реакции на сообщения** | ❌ | ✅ | ❌ |
| **Расширенная поддержка вложений** | ✅ | ❌ | ❌ |
--- ---
## 🏗️ Технологический стек ## 🏗️ Технологический стек
- **Kotlin** — язык программирования, ориентированный на безопасность и производительность - **Kotlin**
- **Compose Multiplatform** — объявленный UI фреймворк для кроссплатформенной разработки - **Compose Multiplatform**
- **Material Design 3** — современные компоненты пользовательского интерфейса - **Material Design 3**
- **Ktor Client** — асинхронный HTTP клиент для сетевых запросов - **Ktor Client**
- **LiveKit** — инфраструктура для видеозвонков и конференций - **LiveKit** — звонки
- **SQLDelight** — тип-безопасные SQL запросы для локального хранилища данных - **SQLDelight** — локальное хранилище
- **Firebase Messaging** — push-уведомления - **Firebase Messaging** — push
- **Coil** — загрузка и кэширование изображений - **Coil** — изображения
---
## 📥 Сборка и разработка (Android Studio) ## 📥 Сборка и разработка (Android Studio)
### Требования ### Требования
- Последняя версия Android Studio - Актуальная Android Studio
- Компоненты, обновленные до последних версий - JDK из Android Studio (JetBrains Runtime)
### Быстрый старт ### Быстрый старт
1. **Клонируйте репозиторий:** 1. **Клонируйте репозиторий:**
```bash
git clone https://github.com/fromchat-messenger/app.git ```bash
cd app git clone https://github.com/fromchat-messenger/android.git
``` cd android
```
2. **Сгенерируйте ключи (Debug & Release):** 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 \ ```bash
-keyalg RSA -keysize 2048 -validity 10000 \ DEBUG_STORE_PASS=CHANGEME
-alias key0 -storepass $DEBUG_STORE_PASS -keypass $DEBUG_KEY_PASS \ DEBUG_KEY_PASS=CHANGEME
-dname "CN=Debug, O=FromChat, C=RU" RELEASE_STORE_PASS=CHANGEME
RELEASE_KEY_PASS=CHANGEME
keytool -genkey -v -keystore app/android/keys/release.jks \ mkdir -p app/android/keys
-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 keytool -genkey -v -keystore app/android/keys/debug.jks \
releaseStorePassword=$RELEASE_STORE_PASS -keyalg RSA -keysize 2048 -validity 10000 \
releaseKeyPassword=$RELEASE_KEY_PASS -alias key0 -storepass $DEBUG_STORE_PASS -keypass $DEBUG_KEY_PASS \
debugStorePassword=$DEBUG_STORE_PASS -dname "CN=Debug, O=FromChat, C=RU"
debugKeyPassword=$DEBUG_KEY_PASS
EOF keytool -genkey -v -keystore app/android/keys/release.jks \
``` -keyalg RSA -keysize 2048 -validity 10000 \
3. **Откройте в Android Studio:** -alias key0 -storepass $RELEASE_STORE_PASS -keypass $RELEASE_KEY_PASS \
- Выберите: `File → Open → app/` -dname "CN=Release, O=FromChat, C=RU"
- Android Studio автоматически загружает все зависимости и синхронизирует Gradle
4. **Запустите:** cat > app/android/keystore.properties << EOF
- Нажмите `Run → Run 'Android'` (Shift+F10) 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/ android/
├── app/ # Код клиента ├── app/
│ ├── android/ # Модуль Android-приложения │ ├── android/ # Android app module
│ └── shared/ # Общий код Compose (Android + iOS) │ └── shared/ # Compose Multiplatform
│ ├── commonMain/ # Кроссплатформенный код │ ├── commonMain/
│ ├── androidMain/ # Android-специфичный код │ ├── androidMain/
│ └── iosMain/ # iOS-специфичный код (еще не готово) │ └── iosMain/ # ещё не готово
├── utils/ # Модуль всяких полезных штук, которые можно использовать в другом проекте ├── utils/ # переиспользуемые утилиты
│ ├── android/ # Модуль Android-библиотеки │ ├── android/
│ └── shared/ │ └── shared/
└── gradle/libs.versions.toml # Управление зависимостями └── 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="month_name_dec">декабря</string>
<string name="server_config_title">Настройка сервера</string> <string name="server_config_title">Настройка сервера</string>
<string name="server_config_subtitle">Подключение к альтернативному серверу FromChat. Это дает больше приватности и контроля над данными.</string> <string name="server_config_subtitle">Подключение к альтернативному серверу FromChat. Это дает больше приватности и контроля над данными.</string>
<string name="server_ip_label">IP или имя сервера</string> <string name="server_ip_label">Сервер</string>
<string name="server_ip_hint">192.168.1.10</string> <string name="server_ip_hint">api.fromchat.ru или host:порт</string>
<string name="api_port_label">Порт</string> <string name="api_port_label">Порт</string>
<string name="api_port_hint"></string> <string name="api_port_hint"></string>
<string name="calls_port_label">Порт звонков</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_hint">HTTPS. Отключайте только для HTTP без шифрования.</string>
<string name="server_config_https_headline">HTTPS</string> <string name="server_config_https_headline">HTTPS</string>
<string name="server_config_https_local_hint">Защищённое соединение. Если сервер локальный, скорее всего вам нужно это отключить.</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_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_ok_calls_skip">Сервер доступен (без звонков), пинг: %1$d мс.</string>
<string name="server_config_snackbar_api_fail">Не удалось подключиться к серверу.</string> <string name="server_config_snackbar_api_fail">Не удалось подключиться к серверу.</string>
<string name="server_config_snackbar_calls_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_ok_api_calls_bad">Сервер OK; звонки не ответили.</string>
<string name="server_config_snackbar_defaults">Сброшено на значения по умолчанию.</string> <string name="server_config_snackbar_defaults">Сброшено на значения по умолчанию.</string>
<string name="server_config_fab_verify">Проверить сервер</string> <string name="server_config_fab_verify">Проверить сервер</string>
<string name="server_config_fab_reset">Сброс</string> <string name="server_config_fab_reset">Сброс</string>
<string name="server_config_action_check">Проверить</string> <string name="server_config_action_check">Проверить</string>
<string name="server_config_action_reset">Сбросить</string> <string name="server_config_action_reset">Сбросить</string>
<string name="server_config_action_reset_confirm_title">Сбросить настройки?</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_unsupported_no_instance_id">Сервер не предоставляет корректный ID экземпляра. Подключение невозможно.</string>
<string name="server_config_snackbar_timeout">Сервер не ответил вовремя. Повторите попытку.</string> <string name="server_config_snackbar_timeout">Сервер не ответил вовремя. Повторите попытку.</string>
<string name="server_config_checking">Проверка…</string> <string name="server_config_checking">Проверка…</string>
@@ -254,28 +254,28 @@
<!-- Server Configuration --> <!-- Server Configuration -->
<string name="server_config_title">Connect to a server</string> <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_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_label">Server</string>
<string name="server_ip_hint">192.168.1.10</string> <string name="server_ip_hint">api.fromchat.ru or host:port</string>
<string name="api_port_label">Port</string> <string name="api_port_label">Port</string>
<string name="calls_port_label">Calls port</string> <string name="calls_port_label">Calls port</string>
<string name="https_enabled">Secure connection</string> <string name="https_enabled">Secure connection</string>
<string name="server_config_https_hint">Uses HTTPS. Turn off only for plain HTTP.</string> <string name="server_config_https_hint">Uses HTTPS. Turn off only for plain HTTP.</string>
<string name="server_config_https_headline">HTTPS</string> <string name="server_config_https_headline">HTTPS</string>
<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_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_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">Server OK. Calls 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_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_api_fail">Cannot reach the API.</string>
<string name="server_config_snackbar_calls_fail">Calls port not reachable.</string> <string name="server_config_snackbar_calls_fail">Calls 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_ok_api_calls_bad">Server OK; calls did not respond.</string>
<string name="server_config_snackbar_defaults">Defaults applied.</string> <string name="server_config_snackbar_defaults">Defaults applied.</string>
<string name="server_config_fab_verify">Check server</string> <string name="server_config_fab_verify">Check server</string>
<string name="server_config_fab_reset">Reset to defaults</string> <string name="server_config_fab_reset">Reset to defaults</string>
<string name="server_config_action_check">Check</string> <string name="server_config_action_check">Check</string>
<string name="server_config_action_reset">Reset</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_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_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_snackbar_timeout">Server did not respond in time. Try again.</string>
<string name="server_config_checking">Checking…</string> <string name="server_config_checking">Checking…</string>
@@ -120,7 +120,7 @@ object CallStore {
fun startOutgoingCall(peerUserId: Int) { fun startOutgoingCall(peerUserId: Int) {
if (peerUserId <= 0 || peerUserId == ApiClient.user?.id) return if (peerUserId <= 0 || peerUserId == ApiClient.user?.id) return
if (!ServerConfig.callsEnabled) { if (!ServerConfig.callsEnabled) {
Logger.d(TAG, "startOutgoingCall ignored (calls disabled: set calls port in server settings)") Logger.d(TAG, "startOutgoingCall ignored (calls disabled)")
return return
} }
Logger.i(TAG, "startOutgoingCall(peer=$peerUserId)") Logger.i(TAG, "startOutgoingCall(peer=$peerUserId)")
@@ -22,26 +22,110 @@ sealed interface ServerProbeResult {
data object Unreachable : 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 { sealed interface ApplyServerResult {
data object Applied : ApplyServerResult data object Applied : ApplyServerResult
data object ServerUnreachable : 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 apiBase = apiBaseUrlFor(config)
val mark = TimeSource.Monotonic.markNow() val mark = TimeSource.Monotonic.markNow()
InstanceIdGuard.probeConfig = config InstanceIdGuard.probeConfig = config
@@ -57,12 +141,19 @@ suspend fun probeServer(config: ServerConfigData): ServerProbeResult {
is InstanceIdResolveResult.Cached -> resolve.instanceId is InstanceIdResolveResult.Cached -> resolve.instanceId
is InstanceIdResolveResult.Fetched -> resolve.instanceId is InstanceIdResolveResult.Fetched -> resolve.instanceId
is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId
InstanceIdResolveResult.Unsupported -> return ServerProbeResult.Unsupported InstanceIdResolveResult.Unsupported ->
InstanceIdResolveResult.Timeout -> return ServerProbeResult.Timeout return ServerProbeResult.Unsupported to null
InstanceIdResolveResult.Unreachable -> return ServerProbeResult.Unreachable 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) val callsOk = probeCallsReachable(config)
return ServerProbeResult.Supported(instanceId, callsOk, pingMs) return ServerProbeResult.Supported(instanceId, callsOk, pingMs) to null
} finally { } finally {
InstanceIdGuard.probeConfig = null InstanceIdGuard.probeConfig = null
} }
@@ -73,7 +164,7 @@ suspend fun applyServerConfig(
instanceId: String, instanceId: String,
callsOk: Boolean, callsOk: Boolean,
) { ) {
val tentative = config.copy(callsEnabled = callsOk) val tentative = config.copy(callsEnabled = callsOk, callsPort = config.apiPort)
ServerConfig.updateServerConfig(tentative) ServerConfig.updateServerConfig(tentative)
DocumentRepository.invalidate() DocumentRepository.invalidate()
val userId = ApiClient.user?.id val userId = ApiClient.user?.id
@@ -30,28 +30,24 @@ object ServerConfig {
_serverConfig.value = config _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 val callsEnabled: Boolean
get() = config.callsEnabled 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 val callsPort: Int
get() = config.callsPort get() = config.apiPort
/** /**
* LiveKit WS endpoint derived from the active server config: * LiveKit WS URL: same host/port as the API (Caddy proxies `/rtc*` to the SFU over TLS).
* - host = [ServerConfigData.serverIp]
* - port = [ServerConfigData.callsPort]
* - scheme = ws or wss based on [ServerConfigData.httpsEnabled]
*/ */
fun liveKitWsUrl(): String { fun liveKitWsUrl(): String {
val scheme = if (config.httpsEnabled) "wss" else "ws" 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`). * LiveKit signaling WebSocket URL via the API reverse proxy (`/livekit/rtc`).
* WebRTC media uses the configured calls port on the server (e.g. HAProxy in front of LiveKit).
*/ */
fun liveKitSignalingWsUrl(): String { fun liveKitSignalingWsUrl(): String {
val scheme = if (config.httpsEnabled) "wss" else "ws" 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 const val DEFAULT_CALLS_PORT = 8303
/** /**
* Server configuration: [serverIp], HTTP(S) [apiPort], [callsPort] for WebRTC / HAProxy (defaults to [DEFAULT_CALLS_PORT]). * Server configuration: [serverIp] + [apiPort] (from a single `host[:port]` field; default port 443).
* [callsEnabled] is updated when saving server settings (probe on the calls port); defaults to true when unset. * [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( data class ServerConfigData(
val serverIp: String, val serverIp: String,
@@ -90,4 +88,4 @@ data class ServerConfigData(
val callsPort: Int, val callsPort: Int,
val httpsEnabled: Boolean, val httpsEnabled: Boolean,
val callsEnabled: Boolean = true, val callsEnabled: Boolean = true,
) )
@@ -108,9 +108,9 @@ object Settings {
suspend fun readServerConfig(): ServerConfigData { suspend fun readServerConfig(): ServerConfigData {
migrateLegacyServerUrlIfNeeded() migrateLegacyServerUrlIfNeeded()
if (!settings.contains(SERVER_IP_KEY)) { 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(API_PORT_KEY, 443)
settings.putInt(CALLS_PORT_KEY, DEFAULT_CALLS_PORT) settings.putInt(CALLS_PORT_KEY, 443)
if (!settings.contains(HTTPS_ENABLED_KEY)) { if (!settings.contains(HTTPS_ENABLED_KEY)) {
settings.putBoolean(HTTPS_ENABLED_KEY, true) settings.putBoolean(HTTPS_ENABLED_KEY, true)
} }
@@ -1,19 +1,21 @@
package ru.fromchat.ui.main.settings.server 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 = internal fun isValidPortNumber(text: String): Boolean =
(text.ifBlank { return true }.toIntOrNull() ?: return false) in 1..65535 (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 -> internal fun isValidIpOrHostname(host: String) = host.trim().let { h ->
!( !(
h.isEmpty() || h.isEmpty() ||
h.length > 253 || h.length > 253 ||
h.any { !isAllowedHostChar(it) } h.any { !(it.isLetterOrDigit() || it in ".:-[]_") }
) && ( ) && (
isValidIpv4(h) || isValidIpv4(h) ||
isValidIpv6(h) ||
isValidIpv6Bracketed(h) || isValidIpv6Bracketed(h) ||
isValidHostname(h) isValidHostname(h)
) )
@@ -28,16 +30,18 @@ private fun isValidIpv4(s: String): Boolean {
return true 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 { private fun isValidIpv6Bracketed(s: String): Boolean {
if (!s.startsWith('[') || !s.contains(']')) return false if (!s.startsWith('[') || !s.contains(']')) return false
val end = s.indexOf(']') val end = s.indexOf(']')
if (end <= 1) return false if (end <= 1) return false
val inner = s.substring(1, end) return isValidIpv6(s.substring(1, end))
if (inner.isBlank() || !inner.all { it.isDigit() || it in "abcdefABCDEF:" }) return false
return inner.count { it == ':' } >= 2
} }
private fun isValidHostname(host: String): Boolean { private fun isValidHostname(host: String): Boolean {
@@ -78,4 +82,4 @@ internal fun hostForAuthority(host: String) =
!it.startsWith("[") !it.startsWith("[")
) "[$it]" ) "[$it]"
else it else it
} }
@@ -25,12 +25,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Dns
import androidx.compose.material.icons.filled.FlashOn 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.RestartAlt
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults 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.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign 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 androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.SwitchListItem
import com.pr0gramm3r101.utils.navigateAndWipeBackStack import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.toDp import com.pr0gramm3r101.utils.toDp
import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeProgressive
@@ -89,18 +81,15 @@ import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api_port_label
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.calls_port_label
import ru.fromchat.cancel import ru.fromchat.cancel
import ru.fromchat.confirm import ru.fromchat.confirm
import ru.fromchat.config.DEFAULT_CALLS_PORT
import ru.fromchat.config.ServerConfigData import ru.fromchat.config.ServerConfigData
import ru.fromchat.config.Settings import ru.fromchat.config.Settings
import ru.fromchat.api.instance.ServerProbeResult import ru.fromchat.api.instance.ServerProbeResult
import ru.fromchat.api.instance.ApplyServerResult import ru.fromchat.api.instance.ApplyServerResult
import ru.fromchat.api.instance.applyServerAndNavigate 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.save_continue
import ru.fromchat.server_config_action_check import ru.fromchat.server_config_action_check
import ru.fromchat.server_config_action_reset 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_action_reset_confirm_title
import ru.fromchat.server_config_checking import ru.fromchat.server_config_checking
import ru.fromchat.server_config_host_error 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_api_fail
import ru.fromchat.server_config_snackbar_defaults import ru.fromchat.server_config_snackbar_defaults
import ru.fromchat.server_config_snackbar_ok_calls 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.rememberLazyListFocusScrollState
import ru.fromchat.ui.components.trackLazyListFocus import ru.fromchat.ui.components.trackLazyListFocus
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import kotlin.math.roundToInt
private object ServerConfigLazyListIndices { private object ServerConfigLazyListIndices {
const val SERVER_IP_FIELD = 2 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( @OptIn(
ExperimentalFoundationApi::class, ExperimentalFoundationApi::class,
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
@@ -164,17 +139,11 @@ fun ServerConfigScreen() {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val density = LocalDensity.current val density = LocalDensity.current
var serverIp by remember { mutableStateOf("") } var serverEndpoint by remember { mutableStateOf("") }
var apiPortText by remember { mutableStateOf("") }
var callsPortText by remember { mutableStateOf("") }
var httpsEnabled by remember { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
Settings.serverConfig.apply { Settings.serverConfig.apply {
serverIp = this.serverIp serverEndpoint = formatServerEndpointForInput(serverIp, apiPort)
apiPortText = this.apiPort.toString()
callsPortText = this.callsPort.toString()
httpsEnabled = this.httpsEnabled
} }
} }
@@ -188,7 +157,6 @@ fun ServerConfigScreen() {
val strSnackbarDefaults = stringResource(Res.string.server_config_snackbar_defaults) val strSnackbarDefaults = stringResource(Res.string.server_config_snackbar_defaults)
val strHostError = stringResource(Res.string.server_config_host_error) 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 strSnackbarApiFail = stringResource(Res.string.server_config_snackbar_api_fail)
val strSnackbarTimeout = stringResource(Res.string.server_config_snackbar_timeout) val strSnackbarTimeout = stringResource(Res.string.server_config_snackbar_timeout)
val strUnsupportedInstance = stringResource(Res.string.server_config_unsupported_no_instance_id) 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 strResetConfirmTitle = stringResource(Res.string.server_config_action_reset_confirm_title)
val strResetConfirmBody = stringResource(Res.string.server_config_action_reset_confirm_body) val strResetConfirmBody = stringResource(Res.string.server_config_action_reset_confirm_body)
val hostOk = serverIp.isNotBlank() && isValidIpOrHostname(serverIp) val parsedEndpoint = remember(serverEndpoint) { parseServerEndpoint(serverEndpoint) }
val apiPortError = apiPortText.isNotEmpty() && !isValidPortNumber(apiPortText) val hostOk = parsedEndpoint != null
val callsPortError = callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText)
val canApply = val canApply = hostOk && !busy
hostOk &&
!apiPortError &&
!callsPortError &&
!busy
fun resetToDefaults() { fun resetToDefaults() {
serverIp = "fromchat.ru" serverEndpoint = "api.fromchat.ru"
apiPortText = "443"
callsPortText = DEFAULT_CALLS_PORT.toString()
httpsEnabled = true
scope.launch { scope.launch {
snackbarHostState.showSnackbar(strSnackbarDefaults) snackbarHostState.showSnackbar(strSnackbarDefaults)
} }
} }
fun buildTentativeConfig(): ServerConfigData? { fun buildTentativeFromParsed(): ParsedServerEndpoint? = parsedEndpoint
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 verifyServer() { fun verifyServer() {
scope.launch { scope.launch {
launch { snackbarHostState.showSnackbar(strChecking) } launch { snackbarHostState.showSnackbar(strChecking) }
val tentative = buildTentativeConfig() val parsed = buildTentativeFromParsed()
if (tentative == null) { if (parsed == null) {
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(strHostError)
if (serverIp.isNotEmpty() && !isValidIpOrHostname(serverIp.trim())) {
strHostError
} else {
strPortError
},
)
return@launch return@launch
} }
val probe = probeServer(tentative) val (tentative, probe) = probeServerEndpoint(
host = parsed.host,
port = parsed.port,
httpsPreferred = parsed.httpsPreferred,
schemeExplicit = parsed.schemeExplicit,
)
lastProbe = probe lastProbe = probe
lastProbedConfig = tentative lastProbedConfig = tentative
val msg = when (probe) { val msg = when (probe) {
@@ -305,11 +248,19 @@ fun ServerConfigScreen() {
try { try {
busy = true busy = true
val tentative = buildTentativeConfig() ?: return@launch val parsed = buildTentativeFromParsed()
val probe = probeServer(tentative).also { if (parsed == null) {
lastProbe = it snackbarHostState.showSnackbar(strHostError)
lastProbedConfig = tentative return@launch
} }
val (tentative, probe) = probeServerEndpoint(
host = parsed.host,
port = parsed.port,
httpsPreferred = parsed.httpsPreferred,
schemeExplicit = parsed.schemeExplicit,
)
lastProbe = probe
lastProbedConfig = tentative
when (probe) { when (probe) {
ServerProbeResult.Unsupported -> { ServerProbeResult.Unsupported -> {
@@ -487,8 +438,8 @@ fun ServerConfigScreen() {
item { item {
OutlinedTextField( OutlinedTextField(
value = serverIp, value = serverEndpoint,
onValueChange = { serverIp = filterHostInput(it) }, onValueChange = { serverEndpoint = filterHostInput(it) },
label = { Text(stringResource(Res.string.server_ip_label)) }, label = { Text(stringResource(Res.string.server_ip_label)) },
placeholder = { Text(stringResource(Res.string.server_ip_hint)) }, placeholder = { Text(stringResource(Res.string.server_ip_hint)) },
modifier = Modifier modifier = Modifier
@@ -499,15 +450,15 @@ fun ServerConfigScreen() {
) )
.padding(horizontal = SettingsStepHorizontalPadding), .padding(horizontal = SettingsStepHorizontalPadding),
singleLine = true, singleLine = true,
isError = !hostOk, isError = serverEndpoint.isNotBlank() && !hostOk,
supportingText = if (!hostOk) {{ supportingText = if (serverEndpoint.isNotBlank() && !hostOk) {{
Text(stringResource(Res.string.server_config_host_error)) Text(stringResource(Res.string.server_config_host_error))
}} else null, }} else null,
colors = fieldColors, colors = fieldColors,
shape = SettingsPasswordOutlineFieldShape, shape = SettingsPasswordOutlineFieldShape,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri, keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next, imeAction = ImeAction.Done,
), ),
leadingIcon = { leadingIcon = {
Icon(Icons.Filled.Dns, null) 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 { 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 { item {
Row( Row(
modifier = Modifier 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
}