mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Simplify server config screen
This commit is contained in:
+91
-75
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-7
@@ -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 {
|
||||||
|
|||||||
+34
-211
@@ -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
|
||||||
|
|||||||
+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