mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Simplify server config screen
This commit is contained in:
+92
-76
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+12
-8
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+34
-211
@@ -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
|
||||
|
||||
+79
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user