Combine Cursor rules into one, overhaul the server configuration screen

This commit is contained in:
2026-04-27 14:49:17 +03:00
Unverified
parent fb7ec80b30
commit 6d9066b0a6
31 changed files with 2000 additions and 741 deletions
@@ -1,23 +0,0 @@
---
description: After Android/KMP app changes—build debug APK, install and launch on a real device; emulator only if user asked or no phone is connected
alwaysApply: true
---
# Build, install, and run on device (after Android-related changes)
Whenever you change anything under **`app:android`**, **`app:shared`** (`commonMain` / `androidMain`), or **`utils:shared`** in ways that affect the Android app:
1. **Build** the debug APK: `./gradlew :app:android:assembleDebug` from the Android repo root. On macOS, use Android Studios bundled JBR (e.g. `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`, or unset `JAVA_HOME` so `gradlew` defaults to it). If that JBR is missing or fails, use another working JDK 21+ (`GRADLE_JAVA_HOME`).
2. **Read Mobile MCP tool schemas** under the projects MCP descriptors for `user-Mobile MCP` before calling tools (required).
3. **`mobile_list_available_devices`** — get all `id`s (and `name` / `type` if present).
4. **Use a real device by default.** Do **not** install or launch on an emulator **unless** the user **explicitly** asked to use the emulator, **or** no physical device is connected and **only** emulator(s) are available. Classify devices using `type != "emulator"` when reliable; if `type` is wrong or missing, treat as physical when the **name** does not look like an AVD (e.g. not `sdk_gphone`, not a generic emulator name). When one or more physical devices are available, install and launch **only** on those—**never** also push to emulators in the same step. When **no** physical device is connected, use every returned target (emulators).
- **All connected phones (e.g. two-device FromChat testing):** When the user asks to **always run on every / both connected devices** (or similar), repeat **`mobile_install_app`** and **`mobile_launch_app`** for **each** connected physical device ID returned by **`mobile_list_available_devices`**, not just one. If only one device is online, use that one; do not fail the step.
5. For **each** chosen device `id`: **`mobile_install_app`** with `path` = absolute path to
`app/android/build/outputs/apk/debug/android-debug.apk`
in this repo.
6. For **each** same device: **`mobile_launch_app`** with `packageName` **`ru.fromchat.beta`**. The debug APK from `assembleDebug` uses `applicationIdSuffix = ".beta"` (`app/android/build.gradle.kts`), so the on-device package is **`ru.fromchat.beta`**, not `ru.fromchat`—`ru.fromchat` will fail to launch after a debug install. (Release / no-suffix id is `ru.fromchat`.)
7. **Smoke-test after launch** (when a device is online): use **`mobile_list_elements_on_screen`** on the chosen device, then exercise the flows your change touched (e.g. open **Settings**, drill into **Appearance**, **Notifications**, **Devices**, **Security** steps, **Account**, **About**—tap through and use **Back**). Fix any crash or obvious broken UI before finishing.
Also run **`:app:shared:compileKotlinIosArm64`** (and fix errors) when shared Kotlin changes should stay valid for iOS—either with the same Gradle invocation as in `general.mdc` or right after the Android APK build.
Skipping install/launch when **no** device is online is acceptable; do not skip the **Gradle build** after code changes.
+79
View File
@@ -0,0 +1,79 @@
---
description: Android/KMP workspace rules (single concise source of truth)
alwaysApply: true
---
# Android / KMP rules
## Project context (quick)
- This is a **messaging app**.
- The Kotlin Multiplatform shared code lives under:
- `app/shared/src/commonMain/kotlin/` (cross-platform logic + Compose UI)
- `app/shared/src/androidMain/kotlin/` (Android-specific implementations)
- `app/shared/src/iosMain/kotlin/` (iOS-specific implementations, when present)
- Android app module: `app/android/`
- Shared UI strings (Compose resources):
- `app/shared/src/commonMain/composeResources/values/strings.xml`
- `app/shared/src/commonMain/composeResources/values-ru/strings.xml`
## Build / validation (required before finishing)
- After changes, run this command (and fix all errors):
- `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" && ./gradlew :app:shared:compileAndroidMain :app:shared:compileKotlinIosArm64`
- If it fails, investigate and try to fix the issue yourself (dont stop at reporting the failure).
## After Android-affecting changes: build + run on device (Mobile MCP)
If you changed anything under `app:android`, `app:shared` (`commonMain` / `androidMain`), or `utils:shared` that affects Android:
- **Build** debug APK: `./gradlew :app:android:assembleDebug` (artifact: `app/android/build/outputs/apk/debug/android-debug.apk`).
- **Install + launch via Cursor Mobile MCP** (configured name **`Mobile MCP`** in `mcp.json`; in Agent MCP tool calls the server id is often **`user-Mobile MCP`**—use whatever id your session lists for `@mobilenext/mobile-mcp`). **Read each tools schema**, then:
1. **`mobile_list_available_devices`** — pick the target **Android** `device` id(s) (prefer a **physical** device when validating UI; use an emulator when the task needs it or no phone is listed).
2. **`mobile_install_app`** — `device`, `path` = **absolute** path to `android-debug.apk` under the repo (e.g. `<workspace>/app/android/build/outputs/apk/debug/android-debug.apk`).
3. **`mobile_launch_app`** — `device`, `packageName` = **`ru.fromchat.beta`** (debug application id; not `ru.fromchat`).
- Smoke-test the affected flows after launch.
- If **`mobile_install_app`** fails on an **emulator** with insufficient storage, use **“Android emulator storage”** (below), then retry.
- Use raw **`adb install` / `am start`** only if Mobile MCP is unavailable after checking MCP status—say so in the reply.
## Calls (LiveKit / `Call*`, call UI, foreground call service, call audio/video)
Whenever the task touches **calls** (e.g. `CallMediaLayer`, `CallForegroundService`, LiveKit wiring, in-call UI, mic/camera/screen-share for calls):
- **Build** debug: `./gradlew :app:android:assembleDebug`
- **Install + launch with Mobile MCP** on **every Android target** you will use for validation (same three tools as above: **`mobile_list_available_devices`** → **`mobile_install_app`** (`device` + absolute `path` to `android-debug.apk`) → **`mobile_launch_app`** (`device`, `packageName` = **`ru.fromchat.beta`**)). Use the MCP **server id** your environment exposes (e.g. **`user-Mobile MCP`**). Repeat for **each** Android `device` id (emulator **and** phone when both appear in `mobile_list_available_devices`).
- Do **not** treat the task as finished until this has been run on **both** an **emulator** and a **physical device** whenever both are available (start/boot the missing one, list devices again, then install + launch on each). If install fails on the emulator, follow **“Android emulator storage”** below, then retry install + launch. If it still fails after a real cleanup attempt, report what you tried.
- If only one class of device is listed, deploy to every listed Android device and state clearly what was missing.
## Android emulator storage (no approval needed)
When an **Android emulator** hits **`INSTALL_FAILED_INSUFFICIENT_STORAGE`** or otherwise has too little free space for a debug install, you may **do whatever is needed to free space on that emulator only** without asking the user first: uninstall third-party apps (`adb -s <emu> uninstall …`), clear caches (`adb shell pm trim-caches` / `rm` under emulator-owned paths the shell can reach), delete arbitrary files **inside that AVD**, or **wipe the AVD / `emulator -wipe-data`** if that is the fastest fix. Treat the emulator as disposable dev state.
- **Never** use this “anything goes” approach on a **physical device** (only uninstall/clear what the user explicitly asked for, or use normal Mobile MCP flows).
## Never touch Gradle caches
- Do not read, list, search, copy, or modify anything under Gradle cache paths (e.g. `~/.gradle/caches`, `**/.gradle/caches`).
## No magic-string “sanitization” of real data
- Never strip/null/rewrite stored or displayed values by comparing to hard-coded UI/placeholder strings.
- Fix at the source (dont persist placeholders) or use explicit migrations/sentinels—never natural-language matching.
## UI strings (shared module)
- No hardcoded user-visible copy in shared UI. Use Compose Multiplatform resources:
- `app/shared/src/commonMain/composeResources/values/strings.xml` (default)
- `app/shared/src/commonMain/composeResources/values-ru/strings.xml` (Russian)
- Keep keys in sync across both files.
- Exception: Debug API screen (`ru.fromchat.ui.debug`) may hardcode strings.
## Prefer utils package APIs
Prefer `com.pr0gramm3r101.utils` when available (before custom solutions), especially:
- Clipboard: `supportClipboardManagerImpl` / `SupportClipboardManager.setText`
- Strings: `String.toAnnotatedString()`
- Compose: `Modifier.conditional`, `CompositionLocal.invoke()`
## Debug HTTP logging
- Prefer Ktor `HttpClient` for debug/instrumentation HTTP logging.
- Do not use `HttpURLConnection` or raw `OkHttpClient` for debug-only logging.
- Centralize behind `ru.fromchat.debug.DebugLogger`, best-effort only (never crash the app).
## Codebase hygiene
- Prefer **official docs** first for typical Jetpack Compose / Compose Multiplatform patterns; if docs cover it, follow them.
- Avoid breaking iOS via Android-only APIs in `commonMain` (keep platform code in `androidMain` / `iosMain` as needed).
- Do not introduce hardcoded user-visible strings outside the Debug API exception (see “UI strings” above).
## Skills (when to use)
- Use the **Material 3 skill** when implementing or changing Compose UI using Material3 components, theming, tokens, or accessibility.
- Use the **using-computer skill** only when the user asks me to control the computer / click through UI / take screenshots.
- Use **Cursor “create-rule/create-skill/create-hook” skills** only when you explicitly ask to create or modify Cursor rules/skills/hooks.
-16
View File
@@ -1,16 +0,0 @@
---
description: Use Ktor HttpClient for debug-mode logging instead of platform APIs
alwaysApply: true
---
# Debug-mode logging with Ktor
- Prefer **Ktor `HttpClient`** for all debug/instrumentation HTTP logging in this project.
- **Do NOT** use `HttpURLConnection`, raw `OkHttpClient`, or other low-level networking APIs for debug-only logging.
- Centralize debug logging behind `ru.fromchat.debug.DebugLogger` and keep it **best-effort only** (never throw, never crash the app).
- When adding new debug logging:
- Use a shared `HttpClient` instance (per platform) configured with the appropriate engine (e.g. OkHttp on Android).
- Send JSON NDJSON-style payloads to the configured debug endpoint.
- Avoid blocking the main thread; run network I/O in coroutines on a background dispatcher.
- Keep debug logging code small and self-contained so it is easy to remove or disable when no longer needed.
-73
View File
@@ -1,73 +0,0 @@
---
alwaysApply: true
---
When working with the mobile app:
- **Always verify before finishing:** run the relevant Gradle compile/build for what you changed (at minimum `:app:shared:compileAndroidMain` and `:app:shared:compileKotlinIosArm64` when `commonMain` edits), fix all errors and warnings that indicate breakage, and do not hand off a change you have not compiled locally.
- After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors.
- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), follow **`android-build-deploy-device.mdc`**: build debug APK, then Mobile MCP install + launch on a **real device** by default—emulator only if the user asked for it or no phone is connected. For **`mobile_launch_app`** after a debug install, use package **`ru.fromchat.beta`** (not `ru.fromchat`); details in that rule. After launch, **smoke-test** the affected flows with **`mobile_list_elements_on_screen`** and navigation (see that rule step 7).
# ULTIMATE SILENCE & EFFICIENCY POLICY
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
- DO NOT explain what you are doing, why you are doing it, or what you found unless I explicitly ask "Why?" or "Explain".
- NO introductory filler ("Sure", "I will", "Looking into it").
- NO status updates ("I've updated the file", "Build successful"). If the tool output shows success, that is enough.
- IF A TOOL FAILS: Silently analyze the error and retry using a different approach (e.g., use write_to_file if search_replace fails twice). Never mention the failure.
- THOUGHT PROCESS: Must be 0 words. Move straight to tool calls.
- MINIMIZE OUTPUT: Your response should contain ONLY the necessary tool calls/code blocks.
- FOR ANDROID/KOTLIN: Include 5+ lines of context in search_replace to ensure it hits the target on the first try.
- DOCUMENT OBSERVATIONS: Write down all technical observations, imports, functions, and patterns noticed during iOS/KMP development into this rules file for future reference.
- DO NOT clean any caches.
# iOS & KMP OBSERVATIONS
## iOS Platform Imports
- `platform.Foundation.*` - Foundation framework (NSString, NSDictionary, NSData, etc.)
- `platform.Security.*` - Security framework (SecItemAdd, SecItemCopyMatching, kSecClass, etc.)
- `platform.CoreFoundation.*` - CoreFoundation framework (CFDictionaryRef, CFTypeRef, etc.)
- `kotlinx.cinterop.*` - C interop utilities (memScoped, alloc, ptr, value, etc.)
- `kotlinx.coroutines.*` - Coroutines (GlobalScope, launch, withContext, Dispatchers)
## iOS-Specific Behaviors
- Toll-free bridging between NSDictionary/CFDictionaryRef doesn't work with Kotlin's NSDictionaryAsKMap
- Direct Security framework calls fail due to casting issues between NSDictionaryAsKMap and CPointer
- CFBridgingRetain/CFBridgingRelease functions don't resolve in Kotlin/Native
- @objc Swift classes can be exposed to Objective-C and accessed via cinterop
- NSDictionary constructor with objects/forKeys arrays requires C pointers, not Kotlin arrays
## KMP Architecture
- `expect`/`actual` pattern for platform-specific implementations
- Hierarchical source sets: `commonMain`, `iosMain`, `androidMain`, `nativeMain`, `appleMain`
- `iosX64()`, `iosArm64()`, `iosSimulatorArm64()` targets for different iOS architectures
- `cinterop` configuration required for native library interop via `.def` files
- `kotlin.mpp.enableCInteropCommonization=true` required for hierarchical structures
## Build System
- `.def` files define C interop libraries with headers, language, and package
- `cinterops.create("name")` or `val name by cinterops.creating` for cinterop setup
- `definitionFile.set(file("path"))` to specify .def file location
- Different HTTP clients: `io.ktor.client.engine.darwin.Darwin` for iOS, `okhttp` for Android
## Runtime Patterns
- `@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)` for experimental C interop
- `@DelicateCoroutinesApi` annotation for GlobalScope.launch
- `memScoped { }` for memory-safe C interop operations
- `GlobalScope.launch { }` for fire-and-forget background operations on iOS
- Platform-specific logging with `ru.fromchat.core.Logger`
## Compose Multiplatform / DrawScope
- In `commonMain`, `Canvas { }` does not always resolve `translate` / `scale` without explicit imports: `androidx.compose.ui.graphics.drawscope.translate` and `androidx.compose.ui.graphics.drawscope.scale`. Use `translate(left = …, top = …)` (not `dx`/`dy`).
## Modifier.conditional
- Use `Modifier.conditional` when you need to apply different modifiers based on a condition.
- Both `if` and `else` closures are `@Composable` and receive the current Modifier; they return a Modifier to be appended.
- Source: `utils/shared/.../Compose.kt` — uses `composed { }`; when true applies `if`, when false applies `else` (default `{ Modifier }`).
## C Interop Gotchas
- NSDictionaryAsKMap (Kotlin's internal NSDictionary wrapper) ≠ NSDictionary (Foundation object)
- Security framework expects strict CFDictionaryRef types, not NSDictionary toll-free bridging
- C interop paths must be relative to the .def file location or use compilerOpts
- Complex Objective-C frameworks like Security are unreliable with direct Kotlin interop
- Use Swift → Objective-C → Kotlin cinterop chain for complex native operations
-22
View File
@@ -1,22 +0,0 @@
---
description: Never touch Gradle caches — no reads, writes, or searches there
alwaysApply: true
---
# Gradle caches — never touch
The agent must **never interact with Gradle cache directories** in any way.
## Forbidden (including “read-only”)
- **Do not** read, open, list, traverse, copy, move, delete, or modify anything under Gradle cache paths (e.g. `~/.gradle/caches`, `**/.gradle/caches`, Gradle user home caches, transform outputs, jars that exist only under caches).
- **Do not** run `grep`, `ripgrep` (`rg`), `find`, `ls`, glob tools, or **any** search or directory listing **inside** those paths — even for “read-only” research.
- **Do not** cite cache paths as the source of truth for framework or dependency source code.
## Use instead
- Project source under the **workspace**
- Official documentation and dependency coordinates in **`gradle/libs.versions.toml`** / **`*.gradle.kts`**
- IDE navigation in the repo
Normal Gradle builds on the developer machine may still use the daemon and local caches; this rule governs **agent** behavior only.
@@ -1,10 +0,0 @@
---
description: Do not filter cache, API, or DB fields by matching fixed UI/placeholder English strings
alwaysApply: true
---
# No magic-string “sanitization” of user or message data
- **Never** strip, null out, or rewrite stored or displayed values by comparing them to hard-coded UI strings (e.g. `"Direct messages"`, `"Direct message"`, `"User 123"`, etc.). Those strings can be legitimate **usernames, display names, or message text**.
- **Prefer**: fix the source (dont persist placeholders; use `null`/absent fields; fix the writer). If legacy bad rows exist, use an explicit **schema/version/migration** or a **documented sentinel** agreed with the backend—not substring or equality checks on natural language.
- Applies especially to: SQLDelight/cache layers, list previews, and any code that “cleans” strings before show or read.
-17
View File
@@ -1,17 +0,0 @@
---
alwaysApply: true
---
# UI strings (shared module)
## No hardcoded user-visible text
- Do not put English or Russian (or other) UI copy directly in Kotlin/Compose for screens users see. Use Compose Multiplatform resources: `app/shared/src/commonMain/composeResources/values/strings.xml` (default English) and `values-ru/strings.xml` (Russian), **same `name` keys** in both files.
- Use `stringResource(Res.string.*)` in `@Composable` (and `import ru.fromchat.*` when needed so `Res.string` extensions resolve). Internal-only tokens (cache keys, shared-element keys, protocol constants) may stay in code.
## When you add or change copy
- Add or update the string in **both** `values/strings.xml` and `values-ru/strings.xml` in the **same** change.
- Prefer **plain language** short words, short sentences; avoid jargon unless necessary (e.g. say “server address” rather than “URL” in labels when it fits).
## Debug API screen — excluded
- The **Debug API** screen (`ru.fromchat.ui.debug`) is not for standard users. You may **hardcode English (or any) strings directly in Kotlin** for that screen and **do not** need to add matching keys in `values/strings.xml` / `values-ru/strings.xml` or keep translations in sync.
- The **entry row** that opens Debug API (e.g. in Settings) stays in compose resources like other settings copy.
-25
View File
@@ -1,25 +0,0 @@
---
description: Prefer utils package APIs when available
globs: "**/*.kt"
alwaysApply: true
---
# Utils Package Preference
When working with the Android/KMP codebase, prefer APIs from `com.pr0gramm3r101.utils` whenever possible.
## Clipboard
- Use `supportClipboardManagerImpl` (Composable) instead of `LocalClipboardManager` / `LocalClipboard`
- `SupportClipboardManager.setText(string: String)` is suspend; call from `scope.launch { }`
## String
- Use `String.toAnnotatedString()` for `AnnotatedString` conversion
## Modifier
- Use `Modifier.conditional` from `com.pr0gramm3r101.utils.Compose` for conditional modifiers
## CompositionLocal
- Use `CompositionLocal.invoke()` for `current` when available
## Other
- Check `Utils.kt`, `Compose.kt`, `Components.kt`, `Adaptive.kt` before implementing custom solutions
+2 -1
View File
@@ -27,4 +27,5 @@ app/android/debug
xcuserdata xcuserdata
google-services.json google-services.json
.cursor/plans .cursor/plans
*.log
+34
View File
@@ -1,5 +1,39 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173"> <code_scheme name="Project" version="173">
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings> <JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings> </JetCodeStyleSettings>
+1
View File
@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
File diff suppressed because it is too large Load Diff
@@ -124,11 +124,32 @@
<string name="month_oct">окт</string> <string name="month_oct">окт</string>
<string name="month_nov">ноя</string> <string name="month_nov">ноя</string>
<string name="month_dec">дек</string> <string name="month_dec">дек</string>
<string name="server_config_title">Подключение к серверу</string> <string name="server_config_title">Настройка сервера</string>
<string name="server_config_subtitle">Введите адрес сервера, к которому хотите подключиться</string> <string name="server_config_subtitle">Подключение к альтернативному серверу FromChat. Это дает больше приватности и контроля над данными.</string>
<string name="server_url_label">Адрес сервера</string> <string name="server_ip_label">IP или имя сервера</string>
<string name="server_url_hint">example.com</string> <string name="server_ip_hint">192.168.1.10</string>
<string name="api_port_label">Порт</string>
<string name="api_port_hint">8301</string>
<string name="calls_port_label">Порт звонков</string>
<string name="calls_port_hint">8302</string>
<string name="https_enabled">Защищённое соединение</string> <string name="https_enabled">Защищённое соединение</string>
<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_port_error">Порт от 1 до 65535.</string>
<string name="server_config_snackbar_ok_calls">Сервер доступен, пинг: %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_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="save_continue">Сохранить и продолжить</string> <string name="save_continue">Сохранить и продолжить</string>
<string name="change_server">Сменить сервер</string> <string name="change_server">Сменить сервер</string>
<string name="change_server_d">Подключиться к альтернативному серверу FromChat и выйти из аккаунта.</string> <string name="change_server_d">Подключиться к альтернативному серверу FromChat и выйти из аккаунта.</string>
@@ -152,10 +152,31 @@
<!-- 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">Enter the server you want to use</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_url_label">Server address</string> <string name="server_ip_label">Server IP or hostname</string>
<string name="server_url_hint">example.com</string> <string name="server_ip_hint">192.168.1.10</string>
<string name="https_enabled">Use a secure connection</string> <string name="api_port_label">Port</string>
<string name="api_port_hint">8301</string>
<string name="calls_port_label">Calls port</string>
<string name="calls_port_hint">8302</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_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_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_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="save_continue">Save and continue</string> <string name="save_continue">Save and continue</string>
<!-- Settings --> <!-- Settings -->
@@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.Settings
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.ui.chat.PublicChatPanelCache import ru.fromchat.ui.chat.PublicChatPanelCache
import ru.fromchat.ui.dm.DmPanelCache import ru.fromchat.ui.dm.DmPanelCache
@@ -173,6 +174,58 @@ object ApiClient {
} }
} }
/** GET-only client: no global 401 handler (used when probing a not-yet-saved server URL). */
private val httpProbe by lazy {
createPlatformHttpClient {
install(ContentNegotiation) {
json(json)
}
install(Logging) {
logger = Logger.SIMPLE
level = LogLevel.INFO
}
val currentDevice = currentDeviceInfo()
val userAgent = buildLoginUserAgent(
osName = currentDevice.osName?.takeIfNotBlank(),
osVersion = currentDevice.osVersion?.takeIfNotBlank(),
model = currentDevice.model?.takeIfNotBlank(),
brand = currentDevice.brand?.takeIfNotBlank(),
)
defaultRequest {
header("User-Agent", userAgent)
}
}
}
suspend fun fetchServerInstanceId(apiBaseUrl: String): String {
val base = apiBaseUrl.trimEnd('/')
return httpProbe.get("$base/instance_id").body<ServerInstanceIdResponse>().instanceId.trim()
}
/** Minimal TCP/HTTP check: true if a GET succeeds without throwing (any HTTP status). */
suspend fun probeHttpGet(url: String): Boolean =
runCatching {
httpProbe.get(url.trim())
true
}.getOrDefault(false)
suspend fun checkAuthAt(apiBaseUrl: String, bearer: String): Boolean =
runCatching {
val base = apiBaseUrl.trimEnd('/')
httpProbe.get("$base/check_auth") {
bearerAuth(bearer)
}.body<CheckAuthResponse>().authenticated
}.getOrDefault(false)
suspend fun refreshServerInstanceFingerprint() {
runCatching {
val id = fetchServerInstanceId(Config.apiBaseUrl)
if (id.isNotEmpty()) {
Settings.lastKnownServerInstanceId = id
}
}
}
private fun String?.takeIfNotBlank(): String? = private fun String?.takeIfNotBlank(): String? =
this?.trim()?.takeIf { it.isNotBlank() } this?.trim()?.takeIf { it.isNotBlank() }
@@ -116,6 +116,18 @@ data class SimpleStatusResponse(
val message: String? = null val message: String? = null
) )
@Serializable
data class ServerInstanceIdResponse(
@SerialName("instance_id") val instanceId: String,
)
@Serializable
data class CheckAuthResponse(
val authenticated: Boolean = false,
val username: String? = null,
val admin: Boolean? = null,
)
@Serializable @Serializable
data class MessagesResponse( data class MessagesResponse(
val status: String, val status: String,
@@ -1,5 +1,8 @@
package ru.fromchat.calls package ru.fromchat.calls
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -16,10 +19,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -27,12 +34,24 @@ import ru.fromchat.*
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache import ru.fromchat.api.ProfileCache
import ru.fromchat.api.visibleDisplayName import ru.fromchat.api.visibleDisplayName
import ru.fromchat.ui.LocalSystemBarsVisibility
@Composable @Composable
fun CallOverlay(modifier: Modifier = Modifier) { fun CallOverlay(modifier: Modifier = Modifier) {
val state by CallStore.ui.collectAsState() val state by CallStore.ui.collectAsState()
val systemBars = LocalSystemBarsVisibility.current
LaunchedEffect(state) {
when (state) {
is CallUiState.InCall,
is CallUiState.Incoming,
-> systemBars?.invoke(false)
else -> systemBars?.invoke(true)
}
}
when (val s = state) { when (val s = state) {
CallUiState.Idle -> return CallUiState.Idle,
is CallUiState.Connecting,
-> return
is CallUiState.Failed -> { is CallUiState.Failed -> {
AlertDialog( AlertDialog(
onDismissRequest = { CallStore.dismissFailed() }, onDismissRequest = { CallStore.dismissFailed() },
@@ -45,24 +64,6 @@ fun CallOverlay(modifier: Modifier = Modifier) {
}, },
) )
} }
is CallUiState.Connecting -> {
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.55f)),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(Res.string.call_status_starting),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
is CallUiState.Incoming -> { is CallUiState.Incoming -> {
val me = ApiClient.user?.id val me = ApiClient.user?.id
val cached = ProfileCache.get(s.fromUserId) val cached = ProfileCache.get(s.fromUserId)
@@ -104,7 +105,27 @@ fun CallOverlay(modifier: Modifier = Modifier) {
} }
} }
is CallUiState.InCall -> { is CallUiState.InCall -> {
Box(modifier = modifier.fillMaxSize()) { val enter = remember(s.session.roomName) { Animatable(0.88f) }
LaunchedEffect(s.session.roomName) {
enter.snapTo(0.88f)
enter.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow,
),
)
}
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
.graphicsLayer {
scaleX = enter.value
scaleY = enter.value
transformOrigin = TransformOrigin(0.5f, 0.42f)
},
) {
CallMediaLayer( CallMediaLayer(
connect = s.session, connect = s.session,
showDialingPlaceholder = false, showDialingPlaceholder = false,
@@ -18,6 +18,7 @@ import ru.fromchat.api.ProfileCache
import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.visibleDisplayName import ru.fromchat.api.visibleDisplayName
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.core.config.Config
private const val TAG = "CallStore" private const val TAG = "CallStore"
@@ -58,7 +59,10 @@ object CallStore {
fun onWebSocketMessage(message: WebSocketMessage) { fun onWebSocketMessage(message: WebSocketMessage) {
if (message.type != "call_signaling") return if (message.type != "call_signaling") return
val data = message.data ?: return val data = message.data ?: run {
Logger.w(TAG, "call_signaling: missing data")
return
}
scope.launch { scope.launch {
runCatching { handleSignalingPayload(data) } runCatching { handleSignalingPayload(data) }
.onFailure { Logger.e(TAG, "call_signaling handling failed", it) } .onFailure { Logger.e(TAG, "call_signaling handling failed", it) }
@@ -70,6 +74,10 @@ object CallStore {
val kind = obj["kind"]?.jsonPrimitive?.contentOrNull val kind = obj["kind"]?.jsonPrimitive?.contentOrNull
val fromUserId = obj["fromUserId"]?.jsonPrimitive?.content?.toIntOrNull() val fromUserId = obj["fromUserId"]?.jsonPrimitive?.content?.toIntOrNull()
val currentId = ApiClient.user?.id ?: return val currentId = ApiClient.user?.id ?: return
Logger.d(
TAG,
"call_signaling payload kind=$kind fromUserId=$fromUserId keys=${obj.keys}",
)
if (kind != null) { if (kind != null) {
if (fromUserId == null || fromUserId == currentId) return if (fromUserId == null || fromUserId == currentId) return
when (kind) { when (kind) {
@@ -84,8 +92,12 @@ object CallStore {
else -> {} else -> {}
} }
} }
"accept" -> {} "accept" -> {
else -> {} Logger.d(TAG, "call_signaling accept from peer=$fromUserId (LiveKit path)")
}
else -> {
Logger.d(TAG, "call_signaling control kind=$kind from=$fromUserId (ignored)")
}
} }
return return
} }
@@ -93,8 +105,13 @@ object CallStore {
val roomName = obj["roomName"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } val roomName = obj["roomName"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
if (serverUrl == null || roomName == null || fromUserId == null) return if (serverUrl == null || roomName == null || fromUserId == null) return
if (fromUserId == currentId) return if (fromUserId == currentId) return
if (!Config.callsEnabled) {
Logger.d(TAG, "call_signaling invite ignored (calls disabled in server config)")
return
}
val fromUsername = obj["fromUsername"]?.jsonPrimitive?.contentOrNull.orEmpty() val fromUsername = obj["fromUsername"]?.jsonPrimitive?.contentOrNull.orEmpty()
if (_ui.value is CallUiState.InCall) return if (_ui.value is CallUiState.InCall) return
Logger.d(TAG, "call_signaling → Incoming from=$fromUserId room=$roomName")
_ui.value = CallUiState.Incoming( _ui.value = CallUiState.Incoming(
fromUserId = fromUserId, fromUserId = fromUserId,
fromUsername = fromUsername, fromUsername = fromUsername,
@@ -105,15 +122,21 @@ 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 (!Config.callsEnabled) {
Logger.d(TAG, "startOutgoingCall ignored (calls disabled: set calls port in server settings)")
return
}
Logger.d(TAG, "startOutgoingCall(peer=$peerUserId)")
scope.launch { scope.launch {
_ui.value = CallUiState.Connecting(peerUserId) // Stay on underlying UI until the room is ready; do not block on callee answering.
runCatching { runCatching {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val tok = ApiClient.fetchLiveKitToken(peerUserId, null) val tok = ApiClient.fetchLiveKitToken(peerUserId, null)
ApiClient.sendLiveKitInvite(peerUserId, tok.roomName, tok.serverUrl) val signalUrl = Config.liveKitSignalingWsUrl()
ApiClient.sendLiveKitInvite(peerUserId, tok.roomName, signalUrl)
val label = peerLabel(peerUserId) val label = peerLabel(peerUserId)
LiveKitConnectSession( LiveKitConnectSession(
serverUrl = tok.serverUrl, serverUrl = signalUrl,
token = tok.token, token = tok.token,
peerUserId = peerUserId, peerUserId = peerUserId,
peerDisplayName = label, peerDisplayName = label,
@@ -121,6 +144,10 @@ object CallStore {
) )
} }
}.onSuccess { session -> }.onSuccess { session ->
Logger.d(
TAG,
"startOutgoingCall → InCall peer=${session.peerUserId} room=${session.roomName}",
)
_ui.value = CallUiState.InCall(session) _ui.value = CallUiState.InCall(session)
}.onFailure { e -> }.onFailure { e ->
Logger.e(TAG, "startOutgoingCall failed", e) Logger.e(TAG, "startOutgoingCall failed", e)
@@ -133,6 +160,10 @@ object CallStore {
fun acceptIncoming() { fun acceptIncoming() {
val inc = _ui.value as? CallUiState.Incoming ?: return val inc = _ui.value as? CallUiState.Incoming ?: return
if (!Config.callsEnabled) {
Logger.d(TAG, "acceptIncoming ignored (calls disabled)")
return
}
scope.launch { scope.launch {
runCatching { runCatching {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@@ -142,7 +173,7 @@ object CallStore {
val display = val display =
if (inc.fromUsername.isNotBlank()) inc.fromUsername else label if (inc.fromUsername.isNotBlank()) inc.fromUsername else label
LiveKitConnectSession( LiveKitConnectSession(
serverUrl = tok.serverUrl, serverUrl = Config.liveKitSignalingWsUrl(),
token = tok.token, token = tok.token,
peerUserId = inc.fromUserId, peerUserId = inc.fromUserId,
peerDisplayName = display, peerDisplayName = display,
@@ -150,6 +181,10 @@ object CallStore {
) )
} }
}.onSuccess { session -> }.onSuccess { session ->
Logger.d(
TAG,
"acceptIncoming → InCall peer=${session.peerUserId} room=${session.roomName}",
)
_ui.value = CallUiState.InCall(session) _ui.value = CallUiState.InCall(session)
}.onFailure { e -> }.onFailure { e ->
Logger.e(TAG, "acceptIncoming failed", e) Logger.e(TAG, "acceptIncoming failed", e)
@@ -12,19 +12,27 @@ import ru.fromchat.api.DeviceSessionInfo
import ru.fromchat.ui.Theme import ru.fromchat.ui.Theme
/** /**
* Server configuration data * Server configuration: [serverIp], HTTP(S) [apiPort], optional [callsPort] (WebRTC / HAProxy; null = calls disabled). First install defaults to fromchat.ru:443 with calls on 8302.
*/ */
data class ServerConfigData( data class ServerConfigData(
val serverUrl: String, val serverIp: String,
val httpsEnabled: Boolean val apiPort: Int,
val callsPort: Int?,
val httpsEnabled: Boolean,
) )
object Settings { object Settings {
private const val MATERIAL_YOU_KEY = "materialYou" private const val MATERIAL_YOU_KEY = "materialYou"
private const val THEME_KEY = "theme" private const val THEME_KEY = "theme"
/** Legacy single field "host:port" or hostname — migrated once to [SERVER_IP_KEY] / [API_PORT_KEY]. */
private const val SERVER_URL_KEY = "server_url" private const val SERVER_URL_KEY = "server_url"
private const val SERVER_IP_KEY = "server_ip"
private const val API_PORT_KEY = "api_port"
/** Stored as Int; values ≤ 0 mean unset (calls disabled). */
private const val CALLS_PORT_KEY = "calls_port"
private const val HTTPS_ENABLED_KEY = "https_enabled" private const val HTTPS_ENABLED_KEY = "https_enabled"
private const val DEVICE_SESSIONS_CACHE_KEY = "device_sessions_cache_v1" private const val DEVICE_SESSIONS_CACHE_KEY = "device_sessions_cache_v1"
private const val LAST_SERVER_INSTANCE_ID_KEY = "last_server_instance_id"
private val settings = PlatformSettings() private val settings = PlatformSettings()
private val deviceSessionsJson = Json { ignoreUnknownKeys = true } private val deviceSessionsJson = Json { ignoreUnknownKeys = true }
@@ -33,8 +41,42 @@ object Settings {
CoroutineScope(Dispatchers.IO).launch(block = block) CoroutineScope(Dispatchers.IO).launch(block = block)
} }
private fun serverConfigNotInitialized(): Nothing private suspend fun migrateLegacyServerUrlIfNeeded() {
= throw IllegalStateException("Server config not initialized") if (settings.contains(SERVER_IP_KEY)) return
val legacy = settings.getString(SERVER_URL_KEY).trim()
if (legacy.isEmpty()) return
val (host, port) = parseHostPortLegacy(legacy)
settings.putString(SERVER_IP_KEY, host)
settings.putInt(API_PORT_KEY, port)
if (!settings.contains(CALLS_PORT_KEY)) {
settings.putInt(CALLS_PORT_KEY, -1)
}
settings.remove(SERVER_URL_KEY)
}
private fun parseHostPortLegacy(legacy: String): Pair<String, Int> {
val t = legacy.trim()
if (t.startsWith("[")) {
val end = t.indexOf(']')
if (end > 0) {
val inside = t.substring(1, end)
val rest = t.substring(end + 1)
if (rest.startsWith(":")) {
val p = rest.removePrefix(":").toIntOrNull() ?: 443
return inside to p
}
return inside to 443
}
}
val idx = t.lastIndexOf(':')
if (idx > 0) {
val portPart = t.substring(idx + 1)
if (portPart.isNotEmpty() && portPart.all { it.isDigit() }) {
return t.substring(0, idx) to portPart.toInt()
}
}
return t to 443
}
var materialYou: Boolean var materialYou: Boolean
get() = runBlocking { settings.getBoolean(MATERIAL_YOU_KEY, true) } get() = runBlocking { settings.getBoolean(MATERIAL_YOU_KEY, true) }
@@ -44,44 +86,51 @@ object Settings {
get() = runBlocking { Theme.entries[settings.getInt(THEME_KEY, Theme.AsSystem.ordinal)] } get() = runBlocking { Theme.entries[settings.getInt(THEME_KEY, Theme.AsSystem.ordinal)] }
set(value) = runIO { settings.putInt(THEME_KEY, value.ordinal) } set(value) = runIO { settings.putInt(THEME_KEY, value.ordinal) }
var serverUrl: String var httpsEnabled: Boolean
get() = runBlocking { get() = runBlocking {
settings.getString(SERVER_URL_KEY).ifEmpty { if (settings.contains(HTTPS_ENABLED_KEY)) {
settings.getBoolean(HTTPS_ENABLED_KEY, true)
} else {
serverConfigNotInitialized() serverConfigNotInitialized()
} }
} }
set(value) = runIO { settings.putString(SERVER_URL_KEY, value) }
var httpsEnabled: Boolean
get() = runBlocking {
if (settings.contains(HTTPS_ENABLED_KEY))
settings.getBoolean(HTTPS_ENABLED_KEY, true)
else serverConfigNotInitialized()
}
set(value) = runIO { settings.putBoolean(HTTPS_ENABLED_KEY, value) } set(value) = runIO { settings.putBoolean(HTTPS_ENABLED_KEY, value) }
val hasServerConfig get() = try { private fun serverConfigNotInitialized(): Nothing =
serverUrl throw IllegalStateException("Server config not initialized")
httpsEnabled
true
} catch (_: IllegalStateException) {
false
}
var serverConfig: ServerConfigData var serverConfig: ServerConfigData
get() { get() = runBlocking {
if (!hasServerConfig) { migrateLegacyServerUrlIfNeeded()
serverUrl = "fromchat.ru" if (!settings.contains(SERVER_IP_KEY)) {
httpsEnabled = true settings.putString(SERVER_IP_KEY, "fromchat.ru")
settings.putInt(API_PORT_KEY, 443)
return ServerConfigData("fromchat.ru", true) settings.putInt(CALLS_PORT_KEY, 8302)
if (!settings.contains(HTTPS_ENABLED_KEY)) {
settings.putBoolean(HTTPS_ENABLED_KEY, true)
}
} }
val ip = settings.getString(SERVER_IP_KEY)
return ServerConfigData(serverUrl, httpsEnabled) if (ip.isBlank()) {
serverConfigNotInitialized()
}
val apiPort = settings.getInt(API_PORT_KEY, 443).coerceIn(1, 65535)
val rawCalls = settings.getInt(CALLS_PORT_KEY, -1)
val calls = if (rawCalls > 0 && rawCalls <= 65535) rawCalls else null
val https = settings.getBoolean(HTTPS_ENABLED_KEY, true)
ServerConfigData(
serverIp = ip,
apiPort = apiPort,
callsPort = calls,
httpsEnabled = https,
)
} }
set(value) { set(value) = runIO {
serverUrl = value.serverUrl settings.putString(SERVER_IP_KEY, value.serverIp.trim())
httpsEnabled = value.httpsEnabled settings.putInt(API_PORT_KEY, value.apiPort.coerceIn(1, 65535))
val callsStored = value.callsPort?.coerceIn(1, 65535) ?: -1
settings.putInt(CALLS_PORT_KEY, callsStored)
settings.putBoolean(HTTPS_ENABLED_KEY, value.httpsEnabled)
} }
/** Cached device sessions (JSON). Shown immediately while refreshing from the network. */ /** Cached device sessions (JSON). Shown immediately while refreshing from the network. */
@@ -91,7 +140,7 @@ object Settings {
runCatching { runCatching {
deviceSessionsJson.decodeFromString( deviceSessionsJson.decodeFromString(
ListSerializer(DeviceSessionInfo.serializer()), ListSerializer(DeviceSessionInfo.serializer()),
raw raw,
) )
}.getOrNull() }.getOrNull()
} }
@@ -100,7 +149,7 @@ object Settings {
runIO { runIO {
val enc = deviceSessionsJson.encodeToString( val enc = deviceSessionsJson.encodeToString(
ListSerializer(DeviceSessionInfo.serializer()), ListSerializer(DeviceSessionInfo.serializer()),
list list,
) )
settings.putString(DEVICE_SESSIONS_CACHE_KEY, enc) settings.putString(DEVICE_SESSIONS_CACHE_KEY, enc)
} }
@@ -109,5 +158,9 @@ object Settings {
fun clearDeviceSessionsCache() { fun clearDeviceSessionsCache() {
runIO { settings.remove(DEVICE_SESSIONS_CACHE_KEY) } runIO { settings.remove(DEVICE_SESSIONS_CACHE_KEY) }
} }
}
/** Last known [ServerInstanceIdResponse.instanceId] from the configured API (empty until first fetch). */
var lastKnownServerInstanceId: String
get() = runBlocking { settings.getString(LAST_SERVER_INSTANCE_ID_KEY, "") }
set(value) = runIO { settings.putString(LAST_SERVER_INSTANCE_ID_KEY, value) }
}
@@ -13,19 +13,20 @@ object Config {
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null) private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow() val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
private val config: ServerConfigData get() { private val config: ServerConfigData
if (_serverConfig.value == null) initialize() get() {
if (_serverConfig.value == null) initialize()
return _serverConfig.value
?: throw IllegalStateException("Server configuration not initialized")
}
return (_serverConfig.value ?: IllegalStateException("Server configuration not initialized")) as ServerConfigData
}
/** /**
* Initialize configuration by loading from storage * Initialize configuration by loading from storage
*/ */
fun initialize() { fun initialize() {
_serverConfig.value = Settings.serverConfig _serverConfig.value = Settings.serverConfig
} }
/** /**
* Update server configuration * Update server configuration
*/ */
@@ -33,16 +34,35 @@ object Config {
Settings.serverConfig = config Settings.serverConfig = config
_serverConfig.value = config _serverConfig.value = config
} }
/** Voice/video calls (LiveKit) are enabled only when a calls (WebRTC) port is configured. */
val callsEnabled: Boolean
get() = config.callsPort != null
/**
* LiveKit signaling WebSocket URL: same host and API port as HTTPS reverse proxy (`/api/livekit/rtc`).
* WebRTC media uses the configured calls port on the server (e.g. HAProxy in front of LiveKit).
*/
fun liveKitSignalingWsUrl(): String {
val scheme = if (config.httpsEnabled) "wss" else "ws"
return "$scheme://${config.serverIp}:${config.apiPort}/api/livekit/rtc"
}
/** /**
* Get API base URL based on current server configuration * Get API base URL based on current server configuration
*/ */
val apiBaseUrl val apiBaseUrl: String
get() = "${if (config.httpsEnabled) "https" else "http"}://${config.serverUrl}/api" get() {
val scheme = if (config.httpsEnabled) "https" else "http"
return "$scheme://${config.serverIp}:${config.apiPort}/api"
}
/** /**
* Get WebSocket URL based on current server configuration * Get WebSocket URL for app chat signaling (proxied on the same port as HTTPS / API).
*/ */
val webSocketUrl val webSocketUrl: String
get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws" get() {
val scheme = if (config.httpsEnabled) "wss" else "ws"
return "$scheme://${config.serverIp}:${config.apiPort}/api/chat/ws"
}
} }
@@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -332,7 +333,7 @@ fun App(
LocalSystemBarsVisibility provides rememberSystemBarsController() LocalSystemBarsVisibility provides rememberSystemBarsController()
) { ) {
if (startDestination != null) { if (startDestination != null) {
val animationSpec = tween<IntOffset>(400) val rootNavMotion = spring<IntOffset>(dampingRatio = 0.88f, stiffness = 420f)
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
NavHost( NavHost(
navController = navController, navController = navController,
@@ -340,25 +341,25 @@ fun App(
enterTransition = { enterTransition = {
slideIntoContainer( slideIntoContainer(
Start, Start,
animationSpec = animationSpec animationSpec = rootNavMotion
) )
}, },
exitTransition = { exitTransition = {
slideOutOfContainer( slideOutOfContainer(
Start, Start,
animationSpec = animationSpec animationSpec = rootNavMotion
) )
}, },
popEnterTransition = { popEnterTransition = {
slideIntoContainer( slideIntoContainer(
End, End,
animationSpec = animationSpec animationSpec = rootNavMotion
) )
}, },
popExitTransition = { popExitTransition = {
slideOutOfContainer( slideOutOfContainer(
End, End,
animationSpec = animationSpec animationSpec = rootNavMotion
) )
} }
) { ) {
@@ -511,25 +512,25 @@ fun App(
enterTransition = { enterTransition = {
when (initialState.destination.route) { when (initialState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
else -> slideIntoContainer(Start, animationSpec = animationSpec) else -> slideIntoContainer(Start, animationSpec = rootNavMotion)
} }
}, },
exitTransition = { exitTransition = {
when (targetState.destination.route) { when (targetState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade) DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
else -> slideOutOfContainer(Start, animationSpec = animationSpec) else -> slideOutOfContainer(Start, animationSpec = rootNavMotion)
} }
}, },
popEnterTransition = { popEnterTransition = {
when (initialState.destination.route) { when (initialState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
else -> slideIntoContainer(End, animationSpec = animationSpec) else -> slideIntoContainer(End, animationSpec = rootNavMotion)
} }
}, },
popExitTransition = { popExitTransition = {
when (targetState.destination.route) { when (targetState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade) DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
else -> slideOutOfContainer(End, animationSpec = animationSpec) else -> slideOutOfContainer(End, animationSpec = rootNavMotion)
} }
}, },
) { entry -> ) { entry ->
@@ -563,7 +564,7 @@ fun App(
) )
} }
settingsSlideComposable("about", animationSpec) { settingsSlideComposable("about", rootNavMotion) {
AboutScreen() AboutScreen()
} }
@@ -573,28 +574,28 @@ fun App(
} }
} }
settingsSlideComposable(SettingsRoutes.Appearance, animationSpec) { settingsSlideComposable(SettingsRoutes.Appearance, rootNavMotion) {
SettingsAppearanceScreen(onBack = { navController.navigateUp() }) SettingsAppearanceScreen(onBack = { navController.navigateUp() })
} }
settingsSlideComposable(SettingsRoutes.ServerTools, animationSpec) { settingsSlideComposable(SettingsRoutes.ServerTools, rootNavMotion) {
SettingsServerToolsScreen( SettingsServerToolsScreen(
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
outerNav = navController outerNav = navController
) )
} }
settingsSlideComposable(SettingsRoutes.Notifications, animationSpec) { settingsSlideComposable(SettingsRoutes.Notifications, rootNavMotion) {
SettingsNotificationsScreen(onBack = { navController.navigateUp() }) SettingsNotificationsScreen(onBack = { navController.navigateUp() })
} }
settingsSlideComposable(SettingsRoutes.Devices, animationSpec) { settingsSlideComposable(SettingsRoutes.Devices, rootNavMotion) {
SettingsDevicesScreen(onBack = { navController.navigateUp() }) SettingsDevicesScreen(onBack = { navController.navigateUp() })
} }
settingsSlideComposable(SettingsRoutes.Security, animationSpec) { settingsSlideComposable(SettingsRoutes.Security, rootNavMotion) {
SettingsSecurityHubScreen( SettingsSecurityHubScreen(
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) } onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) }
) )
} }
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, animationSpec) { settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, rootNavMotion) {
SettingsSecurityPasswordFlowScreen( SettingsSecurityPasswordFlowScreen(
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
onDonePopToHub = { onDonePopToHub = {
@@ -602,7 +603,7 @@ fun App(
} }
) )
} }
settingsSlideComposable(SettingsRoutes.Account, animationSpec) { settingsSlideComposable(SettingsRoutes.Account, rootNavMotion) {
SettingsAccountScreen( SettingsAccountScreen(
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
onLogout = navigateToLoginClearingChat, onLogout = navigateToLoginClearingChat,
@@ -0,0 +1,38 @@
package ru.fromchat.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
/**
* App-wide snackbar styling: elevated surface container instead of inverse surface.
*/
@Composable
fun FromChatSnackbarHost(
hostState: SnackbarHostState,
modifier: Modifier = Modifier,
snackbarModifier: Modifier = Modifier,
shape: Shape = SnackbarDefaults.shape,
) {
val scheme = MaterialTheme.colorScheme
SnackbarHost(
hostState = hostState,
modifier = modifier,
) { data ->
Snackbar(
snackbarData = data,
modifier = snackbarModifier,
shape = shape,
containerColor = scheme.surfaceContainerHigh,
contentColor = scheme.onSurface,
actionColor = scheme.primary,
actionContentColor = scheme.primary,
dismissActionContentColor = scheme.onSurfaceVariant,
)
}
}
@@ -194,6 +194,7 @@ fun LoginScreen(
throw e throw e
} }
ApiClient.persistSessionToStorage(response) ApiClient.persistSessionToStorage(response)
runCatching { ApiClient.refreshServerInstanceFingerprint() }
response response
} }
} }
@@ -199,6 +199,7 @@ fun RegisterScreen(
throw e throw e
} }
ApiClient.persistSessionToStorage(response) ApiClient.persistSessionToStorage(response)
runCatching { ApiClient.refreshServerInstanceFingerprint() }
response response
} }
} }
@@ -286,6 +286,12 @@ fun ChatScreen(
panel.handleWebSocketMessage(message) panel.handleWebSocketMessage(message)
} }
} }
"call_signaling" -> {
Logger.d(
"ChatScreen",
"call_signaling (also dispatched to App global → CallStore); skipping panel",
)
}
else -> { else -> {
Logger.d("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}") Logger.d("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}")
} }
@@ -21,6 +21,7 @@ import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.db.MessageCacheStore import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.api.visibleDisplayName import ru.fromchat.api.visibleDisplayName
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.core.config.Config
import ru.fromchat.crypto.CorruptedDmMessagePlaceholder import ru.fromchat.crypto.CorruptedDmMessagePlaceholder
import ru.fromchat.crypto.DmCiphertextCorruptedException import ru.fromchat.crypto.DmCiphertextCorruptedException
import ru.fromchat.crypto.decryptEnvelope import ru.fromchat.crypto.decryptEnvelope
@@ -418,7 +419,7 @@ class DmPanel(
override suspend fun handleDeleteMessage(messageId: Int) {} override suspend fun handleDeleteMessage(messageId: Int) {}
override fun showCallButton(): Boolean = true override fun showCallButton(): Boolean = Config.callsEnabled
override fun getTypingHandler(): TypingHandler = typingHandler override fun getTypingHandler(): TypingHandler = typingHandler
@@ -22,7 +22,6 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -32,6 +31,7 @@ import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.* import ru.fromchat.*
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.ui.FromChatSnackbarHost
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.utils.exclude import ru.fromchat.utils.exclude
import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.main.settings.SettingsTab
@@ -65,7 +65,7 @@ fun MainScreen(
val isChatsPage = selectedPage == PAGE_CHATS val isChatsPage = selectedPage == PAGE_CHATS
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(hostState = effectiveSnackbarHostState) }, snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) },
bottomBar = { bottomBar = {
NavigationBar { NavigationBar {
NavigationBarItem( NavigationBarItem(
@@ -24,6 +24,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.toPath import androidx.compose.material3.toPath
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -260,8 +261,10 @@ fun SettingsSecurityMorphedPasswordHero(
} }
/** /**
* Large icon in a rounded shape for empty states and smaller heroes. * Large icon clipped with a Material expressive polygon ([MaterialShapes] + [toShape]).
* Default is a circle; pass another library polygon (e.g. cookie-sided) to match adjacent flows.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun SettingsExpressiveIconFrame( fun SettingsExpressiveIconFrame(
icon: ImageVector, icon: ImageVector,
@@ -269,12 +272,14 @@ fun SettingsExpressiveIconFrame(
containerSize: Dp = 112.dp, containerSize: Dp = 112.dp,
iconSize: Dp = 52.dp, iconSize: Dp = 52.dp,
containerColor: Color = MaterialTheme.colorScheme.primaryContainer, containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
contentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer contentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
materialPolygon: RoundedPolygon = MaterialShapes.Circle,
) { ) {
val frameShape = materialPolygon.normalized().toShape()
Box( Box(
modifier = modifier modifier = modifier
.size(containerSize) .size(containerSize)
.clip(RoundedCornerShape(percent = 32)) .clip(frameShape)
.background(containerColor), .background(containerColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -82,10 +82,8 @@ import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.Snackbar
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -115,6 +113,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import ru.fromchat.ui.FromChatSnackbarHost
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.components.SwitchListItem import com.pr0gramm3r101.components.SwitchListItem
@@ -577,15 +576,7 @@ fun SettingsNotificationsScreen(onBack: () -> Unit) {
val unexpectedErrorText = stringResource(Res.string.error_unexpected) val unexpectedErrorText = stringResource(Res.string.error_unexpected)
Scaffold( Scaffold(
snackbarHost = { snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
SnackbarHost(hostState = snackbarHostState) {
Snackbar(
snackbarData = it,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
MediumTopAppBar( MediumTopAppBar(
@@ -1128,7 +1119,7 @@ fun SettingsDevicesScreen(onBack: () -> Unit) {
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = { topBar = {
MediumTopAppBar( MediumTopAppBar(
title = { Text(stringResource(Res.string.settings_devices_title)) }, title = { Text(stringResource(Res.string.settings_devices_title)) },
@@ -1763,22 +1754,15 @@ fun SettingsSecurityPasswordFlowScreen(onBack: () -> Unit, onDonePopToHub: () ->
} }
} }
SnackbarHost( FromChatSnackbarHost(
hostState = snackbarHostState, hostState = snackbarHostState,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(horizontal = SettingsStepHorizontalPadding) .padding(horizontal = SettingsStepHorizontalPadding)
.padding(bottom = 76.dp) .padding(bottom = 76.dp)
.fillMaxWidth(), .fillMaxWidth(),
snackbar = { data -> snackbarModifier = Modifier.fillMaxWidth(),
Snackbar( shape = RoundedCornerShape(16.dp),
snackbarData = data,
modifier = Modifier.fillMaxWidth(),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(16.dp),
)
},
) )
} }
} }
@@ -1909,7 +1893,7 @@ fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePass
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = { topBar = {
MediumTopAppBar( MediumTopAppBar(
title = { Text(stringResource(Res.string.settings_account_title)) }, title = { Text(stringResource(Res.string.settings_account_title)) },
@@ -0,0 +1,62 @@
package ru.fromchat.ui.setup
private fun isAllowedHostChar(ch: Char): Boolean =
ch.isLetterOrDigit() || ch in ".:-[]_"
internal fun filterHostInput(raw: String): String =
raw.filter(::isAllowedHostChar).take(253)
internal fun isValidPortNumber(text: String): Boolean {
if (text.isBlank()) return true
val n = text.toIntOrNull() ?: return false
return n in 1..65535
}
internal fun isValidIpOrHostname(host: String): Boolean {
val h = host.trim()
if (h.isEmpty() || h.length > 253) return false
if (h.any { !isAllowedHostChar(it) }) return false
return isValidIpv4(h) || isValidIpv6Bracketed(h) || isValidHostname(h)
}
private fun isValidIpv4(s: String): Boolean {
val p = s.split('.')
if (p.size != 4) return false
for (x in p) {
val n = x.toIntOrNull() ?: return false
if (n !in 0..255) return false
}
return true
}
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()) return false
if (!inner.all { it.isDigit() || it in "abcdefABCDEF:" }) return false
val colons = inner.count { it == ':' }
return colons >= 2
}
private fun isValidHostname(host: String): Boolean {
if (host.contains(':')) return false
if (host.startsWith('[')) return false
if (host.endsWith('.')) return false
val labels = host.split('.')
if (labels.isEmpty() || labels.size > 127) return false
for (label in labels) {
if (label.isEmpty() || label.length > 63) return false
if (label.startsWith('-') || label.endsWith('-')) return false
if (!label.all { it.isLetterOrDigit() || it == '-' || it == '_' }) return false
}
return true
}
/** Host part for URL authority (brackets IPv6). */
internal fun hostForAuthority(host: String): String {
val t = host.trim()
if (t.contains(':') && !t.startsWith("[")) return "[$t]"
return t
}
File diff suppressed because it is too large Load Diff