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
google-services.json
.cursor/plans
.cursor/plans
*.log
+34
View File
@@ -1,5 +1,39 @@
<component name="ProjectCodeStyleConfiguration">
<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>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
+1
View File
@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
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_nov">ноя</string>
<string name="month_dec">дек</string>
<string name="server_config_title">Подключение к серверу</string>
<string name="server_config_subtitle">Введите адрес сервера, к которому хотите подключиться</string>
<string name="server_url_label">Адрес сервера</string>
<string name="server_url_hint">example.com</string>
<string name="server_config_title">Настройка сервера</string>
<string name="server_config_subtitle">Подключение к альтернативному серверу FromChat. Это дает больше приватности и контроля над данными.</string>
<string name="server_ip_label">IP или имя сервера</string>
<string name="server_ip_hint">192.168.1.10</string>
<string name="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="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="change_server">Сменить сервер</string>
<string name="change_server_d">Подключиться к альтернативному серверу FromChat и выйти из аккаунта.</string>
@@ -152,10 +152,31 @@
<!-- Server Configuration -->
<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_url_label">Server address</string>
<string name="server_url_hint">example.com</string>
<string name="https_enabled">Use a secure connection</string>
<string name="server_config_subtitle">Connect to an alternative FromChat server. This can give you more privacy and control over data.</string>
<string name="server_ip_label">Server IP or hostname</string>
<string name="server_ip_hint">192.168.1.10</string>
<string name="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>
<!-- Settings -->
@@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.Settings
import ru.fromchat.core.config.Config
import ru.fromchat.ui.chat.PublicChatPanelCache
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? =
this?.trim()?.takeIf { it.isNotBlank() }
@@ -116,6 +116,18 @@ data class SimpleStatusResponse(
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
data class MessagesResponse(
val status: String,
@@ -1,5 +1,8 @@
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -16,10 +19,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
@@ -27,12 +34,24 @@ import ru.fromchat.*
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.visibleDisplayName
import ru.fromchat.ui.LocalSystemBarsVisibility
@Composable
fun CallOverlay(modifier: Modifier = Modifier) {
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) {
CallUiState.Idle -> return
CallUiState.Idle,
is CallUiState.Connecting,
-> return
is CallUiState.Failed -> {
AlertDialog(
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 -> {
val me = ApiClient.user?.id
val cached = ProfileCache.get(s.fromUserId)
@@ -104,7 +105,27 @@ fun CallOverlay(modifier: Modifier = Modifier) {
}
}
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(
connect = s.session,
showDialingPlaceholder = false,
@@ -18,6 +18,7 @@ import ru.fromchat.api.ProfileCache
import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.visibleDisplayName
import ru.fromchat.core.Logger
import ru.fromchat.core.config.Config
private const val TAG = "CallStore"
@@ -58,7 +59,10 @@ object CallStore {
fun onWebSocketMessage(message: WebSocketMessage) {
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 {
runCatching { handleSignalingPayload(data) }
.onFailure { Logger.e(TAG, "call_signaling handling failed", it) }
@@ -70,6 +74,10 @@ object CallStore {
val kind = obj["kind"]?.jsonPrimitive?.contentOrNull
val fromUserId = obj["fromUserId"]?.jsonPrimitive?.content?.toIntOrNull()
val currentId = ApiClient.user?.id ?: return
Logger.d(
TAG,
"call_signaling payload kind=$kind fromUserId=$fromUserId keys=${obj.keys}",
)
if (kind != null) {
if (fromUserId == null || fromUserId == currentId) return
when (kind) {
@@ -84,8 +92,12 @@ object CallStore {
else -> {}
}
}
"accept" -> {}
else -> {}
"accept" -> {
Logger.d(TAG, "call_signaling accept from peer=$fromUserId (LiveKit path)")
}
else -> {
Logger.d(TAG, "call_signaling control kind=$kind from=$fromUserId (ignored)")
}
}
return
}
@@ -93,8 +105,13 @@ object CallStore {
val roomName = obj["roomName"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
if (serverUrl == null || roomName == null || fromUserId == null) 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()
if (_ui.value is CallUiState.InCall) return
Logger.d(TAG, "call_signaling → Incoming from=$fromUserId room=$roomName")
_ui.value = CallUiState.Incoming(
fromUserId = fromUserId,
fromUsername = fromUsername,
@@ -105,15 +122,21 @@ object CallStore {
fun startOutgoingCall(peerUserId: Int) {
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 {
_ui.value = CallUiState.Connecting(peerUserId)
// Stay on underlying UI until the room is ready; do not block on callee answering.
runCatching {
withContext(Dispatchers.Default) {
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)
LiveKitConnectSession(
serverUrl = tok.serverUrl,
serverUrl = signalUrl,
token = tok.token,
peerUserId = peerUserId,
peerDisplayName = label,
@@ -121,6 +144,10 @@ object CallStore {
)
}
}.onSuccess { session ->
Logger.d(
TAG,
"startOutgoingCall → InCall peer=${session.peerUserId} room=${session.roomName}",
)
_ui.value = CallUiState.InCall(session)
}.onFailure { e ->
Logger.e(TAG, "startOutgoingCall failed", e)
@@ -133,6 +160,10 @@ object CallStore {
fun acceptIncoming() {
val inc = _ui.value as? CallUiState.Incoming ?: return
if (!Config.callsEnabled) {
Logger.d(TAG, "acceptIncoming ignored (calls disabled)")
return
}
scope.launch {
runCatching {
withContext(Dispatchers.Default) {
@@ -142,7 +173,7 @@ object CallStore {
val display =
if (inc.fromUsername.isNotBlank()) inc.fromUsername else label
LiveKitConnectSession(
serverUrl = tok.serverUrl,
serverUrl = Config.liveKitSignalingWsUrl(),
token = tok.token,
peerUserId = inc.fromUserId,
peerDisplayName = display,
@@ -150,6 +181,10 @@ object CallStore {
)
}
}.onSuccess { session ->
Logger.d(
TAG,
"acceptIncoming → InCall peer=${session.peerUserId} room=${session.roomName}",
)
_ui.value = CallUiState.InCall(session)
}.onFailure { e ->
Logger.e(TAG, "acceptIncoming failed", e)
@@ -12,19 +12,27 @@ import ru.fromchat.api.DeviceSessionInfo
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(
val serverUrl: String,
val httpsEnabled: Boolean
val serverIp: String,
val apiPort: Int,
val callsPort: Int?,
val httpsEnabled: Boolean,
)
object Settings {
private const val MATERIAL_YOU_KEY = "materialYou"
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_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 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 deviceSessionsJson = Json { ignoreUnknownKeys = true }
@@ -33,8 +41,42 @@ object Settings {
CoroutineScope(Dispatchers.IO).launch(block = block)
}
private fun serverConfigNotInitialized(): Nothing
= throw IllegalStateException("Server config not initialized")
private suspend fun migrateLegacyServerUrlIfNeeded() {
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
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)] }
set(value) = runIO { settings.putInt(THEME_KEY, value.ordinal) }
var serverUrl: String
var httpsEnabled: Boolean
get() = runBlocking {
settings.getString(SERVER_URL_KEY).ifEmpty {
if (settings.contains(HTTPS_ENABLED_KEY)) {
settings.getBoolean(HTTPS_ENABLED_KEY, true)
} else {
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) }
val hasServerConfig get() = try {
serverUrl
httpsEnabled
true
} catch (_: IllegalStateException) {
false
}
private fun serverConfigNotInitialized(): Nothing =
throw IllegalStateException("Server config not initialized")
var serverConfig: ServerConfigData
get() {
if (!hasServerConfig) {
serverUrl = "fromchat.ru"
httpsEnabled = true
return ServerConfigData("fromchat.ru", true)
get() = runBlocking {
migrateLegacyServerUrlIfNeeded()
if (!settings.contains(SERVER_IP_KEY)) {
settings.putString(SERVER_IP_KEY, "fromchat.ru")
settings.putInt(API_PORT_KEY, 443)
settings.putInt(CALLS_PORT_KEY, 8302)
if (!settings.contains(HTTPS_ENABLED_KEY)) {
settings.putBoolean(HTTPS_ENABLED_KEY, true)
}
}
return ServerConfigData(serverUrl, httpsEnabled)
val ip = settings.getString(SERVER_IP_KEY)
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) {
serverUrl = value.serverUrl
httpsEnabled = value.httpsEnabled
set(value) = runIO {
settings.putString(SERVER_IP_KEY, value.serverIp.trim())
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. */
@@ -91,7 +140,7 @@ object Settings {
runCatching {
deviceSessionsJson.decodeFromString(
ListSerializer(DeviceSessionInfo.serializer()),
raw
raw,
)
}.getOrNull()
}
@@ -100,7 +149,7 @@ object Settings {
runIO {
val enc = deviceSessionsJson.encodeToString(
ListSerializer(DeviceSessionInfo.serializer()),
list
list,
)
settings.putString(DEVICE_SESSIONS_CACHE_KEY, enc)
}
@@ -109,5 +158,9 @@ object Settings {
fun clearDeviceSessionsCache() {
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)
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
private val config: ServerConfigData get() {
if (_serverConfig.value == null) initialize()
private val config: ServerConfigData
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
*/
fun initialize() {
_serverConfig.value = Settings.serverConfig
}
/**
* Update server configuration
*/
@@ -33,16 +34,35 @@ object Config {
Settings.serverConfig = 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
*/
val apiBaseUrl
get() = "${if (config.httpsEnabled) "https" else "http"}://${config.serverUrl}/api"
val apiBaseUrl: String
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
get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws"
val webSocketUrl: String
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.SharedTransitionLayout
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -332,7 +333,7 @@ fun App(
LocalSystemBarsVisibility provides rememberSystemBarsController()
) {
if (startDestination != null) {
val animationSpec = tween<IntOffset>(400)
val rootNavMotion = spring<IntOffset>(dampingRatio = 0.88f, stiffness = 420f)
Box(Modifier.fillMaxSize()) {
NavHost(
navController = navController,
@@ -340,25 +341,25 @@ fun App(
enterTransition = {
slideIntoContainer(
Start,
animationSpec = animationSpec
animationSpec = rootNavMotion
)
},
exitTransition = {
slideOutOfContainer(
Start,
animationSpec = animationSpec
animationSpec = rootNavMotion
)
},
popEnterTransition = {
slideIntoContainer(
End,
animationSpec = animationSpec
animationSpec = rootNavMotion
)
},
popExitTransition = {
slideOutOfContainer(
End,
animationSpec = animationSpec
animationSpec = rootNavMotion
)
}
) {
@@ -511,25 +512,25 @@ fun App(
enterTransition = {
when (initialState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
else -> slideIntoContainer(Start, animationSpec = animationSpec)
else -> slideIntoContainer(Start, animationSpec = rootNavMotion)
}
},
exitTransition = {
when (targetState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
else -> slideOutOfContainer(Start, animationSpec = animationSpec)
else -> slideOutOfContainer(Start, animationSpec = rootNavMotion)
}
},
popEnterTransition = {
when (initialState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
else -> slideIntoContainer(End, animationSpec = animationSpec)
else -> slideIntoContainer(End, animationSpec = rootNavMotion)
}
},
popExitTransition = {
when (targetState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
else -> slideOutOfContainer(End, animationSpec = animationSpec)
else -> slideOutOfContainer(End, animationSpec = rootNavMotion)
}
},
) { entry ->
@@ -563,7 +564,7 @@ fun App(
)
}
settingsSlideComposable("about", animationSpec) {
settingsSlideComposable("about", rootNavMotion) {
AboutScreen()
}
@@ -573,28 +574,28 @@ fun App(
}
}
settingsSlideComposable(SettingsRoutes.Appearance, animationSpec) {
settingsSlideComposable(SettingsRoutes.Appearance, rootNavMotion) {
SettingsAppearanceScreen(onBack = { navController.navigateUp() })
}
settingsSlideComposable(SettingsRoutes.ServerTools, animationSpec) {
settingsSlideComposable(SettingsRoutes.ServerTools, rootNavMotion) {
SettingsServerToolsScreen(
onBack = { navController.navigateUp() },
outerNav = navController
)
}
settingsSlideComposable(SettingsRoutes.Notifications, animationSpec) {
settingsSlideComposable(SettingsRoutes.Notifications, rootNavMotion) {
SettingsNotificationsScreen(onBack = { navController.navigateUp() })
}
settingsSlideComposable(SettingsRoutes.Devices, animationSpec) {
settingsSlideComposable(SettingsRoutes.Devices, rootNavMotion) {
SettingsDevicesScreen(onBack = { navController.navigateUp() })
}
settingsSlideComposable(SettingsRoutes.Security, animationSpec) {
settingsSlideComposable(SettingsRoutes.Security, rootNavMotion) {
SettingsSecurityHubScreen(
onBack = { navController.navigateUp() },
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) }
)
}
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, animationSpec) {
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, rootNavMotion) {
SettingsSecurityPasswordFlowScreen(
onBack = { navController.navigateUp() },
onDonePopToHub = {
@@ -602,7 +603,7 @@ fun App(
}
)
}
settingsSlideComposable(SettingsRoutes.Account, animationSpec) {
settingsSlideComposable(SettingsRoutes.Account, rootNavMotion) {
SettingsAccountScreen(
onBack = { navController.navigateUp() },
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
}
ApiClient.persistSessionToStorage(response)
runCatching { ApiClient.refreshServerInstanceFingerprint() }
response
}
}
@@ -199,6 +199,7 @@ fun RegisterScreen(
throw e
}
ApiClient.persistSessionToStorage(response)
runCatching { ApiClient.refreshServerInstanceFingerprint() }
response
}
}
@@ -286,6 +286,12 @@ fun ChatScreen(
panel.handleWebSocketMessage(message)
}
}
"call_signaling" -> {
Logger.d(
"ChatScreen",
"call_signaling (also dispatched to App global → CallStore); skipping panel",
)
}
else -> {
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.visibleDisplayName
import ru.fromchat.core.Logger
import ru.fromchat.core.config.Config
import ru.fromchat.crypto.CorruptedDmMessagePlaceholder
import ru.fromchat.crypto.DmCiphertextCorruptedException
import ru.fromchat.crypto.decryptEnvelope
@@ -418,7 +419,7 @@ class DmPanel(
override suspend fun handleDeleteMessage(messageId: Int) {}
override fun showCallButton(): Boolean = true
override fun showCallButton(): Boolean = Config.callsEnabled
override fun getTypingHandler(): TypingHandler = typingHandler
@@ -22,7 +22,6 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -32,6 +31,7 @@ import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.*
import ru.fromchat.api.ApiClient
import ru.fromchat.ui.FromChatSnackbarHost
import ru.fromchat.ui.LocalNavController
import ru.fromchat.utils.exclude
import ru.fromchat.ui.main.settings.SettingsTab
@@ -65,7 +65,7 @@ fun MainScreen(
val isChatsPage = selectedPage == PAGE_CHATS
Scaffold(
snackbarHost = { SnackbarHost(hostState = effectiveSnackbarHostState) },
snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) },
bottomBar = {
NavigationBar {
NavigationBarItem(
@@ -24,6 +24,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.toPath
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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
fun SettingsExpressiveIconFrame(
icon: ImageVector,
@@ -269,12 +272,14 @@ fun SettingsExpressiveIconFrame(
containerSize: Dp = 112.dp,
iconSize: Dp = 52.dp,
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(
modifier = modifier
.size(containerSize)
.clip(RoundedCornerShape(percent = 32))
.clip(frameShape)
.background(containerColor),
contentAlignment = Alignment.Center
) {
@@ -82,10 +82,8 @@ import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.Snackbar
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.unit.dp
import androidx.navigation.NavController
import ru.fromchat.ui.FromChatSnackbarHost
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.components.SwitchListItem
@@ -577,15 +576,7 @@ fun SettingsNotificationsScreen(onBack: () -> Unit) {
val unexpectedErrorText = stringResource(Res.string.error_unexpected)
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState) {
Snackbar(
snackbarData = it,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface
)
}
},
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
@@ -1128,7 +1119,7 @@ fun SettingsDevicesScreen(onBack: () -> Unit) {
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = {
MediumTopAppBar(
title = { Text(stringResource(Res.string.settings_devices_title)) },
@@ -1763,22 +1754,15 @@ fun SettingsSecurityPasswordFlowScreen(onBack: () -> Unit, onDonePopToHub: () ->
}
}
SnackbarHost(
FromChatSnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = SettingsStepHorizontalPadding)
.padding(bottom = 76.dp)
.fillMaxWidth(),
snackbar = { data ->
Snackbar(
snackbarData = data,
modifier = Modifier.fillMaxWidth(),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(16.dp),
)
},
snackbarModifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
)
}
}
@@ -1909,7 +1893,7 @@ fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePass
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = {
MediumTopAppBar(
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