mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Combine Cursor rules into one, overhaul the server configuration screen
This commit is contained in:
@@ -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 Studio’s 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 project’s 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.
|
||||
@@ -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 (don’t 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 tool’s 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 (don’t 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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 (don’t 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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -28,3 +28,4 @@ xcuserdata
|
||||
google-services.json
|
||||
|
||||
.cursor/plans
|
||||
*.log
|
||||
Generated
+34
@@ -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>
|
||||
|
||||
@@ -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,11 +13,12 @@ object Config {
|
||||
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
|
||||
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
|
||||
|
||||
private val config: ServerConfigData get() {
|
||||
if (_serverConfig.value == null) initialize()
|
||||
|
||||
return (_serverConfig.value ?: IllegalStateException("Server configuration not initialized")) as ServerConfigData
|
||||
}
|
||||
private val config: ServerConfigData
|
||||
get() {
|
||||
if (_serverConfig.value == null) initialize()
|
||||
return _serverConfig.value
|
||||
?: throw IllegalStateException("Server configuration not initialized")
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize configuration by loading from storage
|
||||
@@ -34,15 +35,34 @@ object 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
Reference in New Issue
Block a user