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
|
|
||||||
+2
-1
@@ -27,4 +27,5 @@ app/android/debug
|
|||||||
xcuserdata
|
xcuserdata
|
||||||
google-services.json
|
google-services.json
|
||||||
|
|
||||||
.cursor/plans
|
.cursor/plans
|
||||||
|
*.log
|
||||||
Generated
+34
@@ -1,5 +1,39 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<code_scheme name="Project" version="173">
|
<code_scheme name="Project" version="173">
|
||||||
|
<JavaCodeStyleSettings>
|
||||||
|
<option name="IMPORT_LAYOUT_TABLE">
|
||||||
|
<value>
|
||||||
|
<package name="android" withSubpackages="true" static="true" />
|
||||||
|
<package name="androidx" withSubpackages="true" static="true" />
|
||||||
|
<package name="com" withSubpackages="true" static="true" />
|
||||||
|
<package name="junit" withSubpackages="true" static="true" />
|
||||||
|
<package name="net" withSubpackages="true" static="true" />
|
||||||
|
<package name="org" withSubpackages="true" static="true" />
|
||||||
|
<package name="java" withSubpackages="true" static="true" />
|
||||||
|
<package name="javax" withSubpackages="true" static="true" />
|
||||||
|
<package name="" withSubpackages="true" static="true" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="android" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="androidx" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="com" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="junit" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="net" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="org" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="java" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="javax" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
</JavaCodeStyleSettings>
|
||||||
<JetCodeStyleSettings>
|
<JetCodeStyleSettings>
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
</JetCodeStyleSettings>
|
</JetCodeStyleSettings>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -124,11 +124,32 @@
|
|||||||
<string name="month_oct">окт</string>
|
<string name="month_oct">окт</string>
|
||||||
<string name="month_nov">ноя</string>
|
<string name="month_nov">ноя</string>
|
||||||
<string name="month_dec">дек</string>
|
<string name="month_dec">дек</string>
|
||||||
<string name="server_config_title">Подключение к серверу</string>
|
<string name="server_config_title">Настройка сервера</string>
|
||||||
<string name="server_config_subtitle">Введите адрес сервера, к которому хотите подключиться</string>
|
<string name="server_config_subtitle">Подключение к альтернативному серверу FromChat. Это дает больше приватности и контроля над данными.</string>
|
||||||
<string name="server_url_label">Адрес сервера</string>
|
<string name="server_ip_label">IP или имя сервера</string>
|
||||||
<string name="server_url_hint">example.com</string>
|
<string name="server_ip_hint">192.168.1.10</string>
|
||||||
|
<string name="api_port_label">Порт</string>
|
||||||
|
<string name="api_port_hint">8301</string>
|
||||||
|
<string name="calls_port_label">Порт звонков</string>
|
||||||
|
<string name="calls_port_hint">8302</string>
|
||||||
<string name="https_enabled">Защищённое соединение</string>
|
<string name="https_enabled">Защищённое соединение</string>
|
||||||
|
<string name="server_config_https_hint">HTTPS. Отключайте только для HTTP без шифрования.</string>
|
||||||
|
<string name="server_config_https_headline">HTTPS</string>
|
||||||
|
<string name="server_config_https_local_hint">Защищённое соединение. Если сервер локальный, скорее всего вам нужно это отключить.</string>
|
||||||
|
<string name="server_config_host_error">Укажите корректный IP или имя хоста.</string>
|
||||||
|
<string name="server_config_port_error">Порт от 1 до 65535.</string>
|
||||||
|
<string name="server_config_snackbar_ok_calls">Сервер доступен, пинг: %1$d мс.</string>
|
||||||
|
<string name="server_config_snackbar_ok_calls_skip">Сервер доступен (без звонков), пинг: %1$d мс.</string>
|
||||||
|
<string name="server_config_snackbar_api_fail">Не удалось подключиться к серверу.</string>
|
||||||
|
<string name="server_config_snackbar_calls_fail">Порт звонков недоступен.</string>
|
||||||
|
<string name="server_config_snackbar_ok_api_calls_bad">Сервер OK; порт звонков не ответил.</string>
|
||||||
|
<string name="server_config_snackbar_defaults">Сброшено на значения по умолчанию.</string>
|
||||||
|
<string name="server_config_fab_verify">Проверить сервер</string>
|
||||||
|
<string name="server_config_fab_reset">Сброс</string>
|
||||||
|
<string name="server_config_action_check">Проверить</string>
|
||||||
|
<string name="server_config_action_reset">Сбросить</string>
|
||||||
|
<string name="server_config_action_reset_confirm_title">Сбросить настройки?</string>
|
||||||
|
<string name="server_config_action_reset_confirm_body">Будут восстановлены адрес сервера и порты по умолчанию.</string>
|
||||||
<string name="save_continue">Сохранить и продолжить</string>
|
<string name="save_continue">Сохранить и продолжить</string>
|
||||||
<string name="change_server">Сменить сервер</string>
|
<string name="change_server">Сменить сервер</string>
|
||||||
<string name="change_server_d">Подключиться к альтернативному серверу FromChat и выйти из аккаунта.</string>
|
<string name="change_server_d">Подключиться к альтернативному серверу FromChat и выйти из аккаунта.</string>
|
||||||
|
|||||||
@@ -152,10 +152,31 @@
|
|||||||
|
|
||||||
<!-- Server Configuration -->
|
<!-- Server Configuration -->
|
||||||
<string name="server_config_title">Connect to a server</string>
|
<string name="server_config_title">Connect to a server</string>
|
||||||
<string name="server_config_subtitle">Enter the server you want to use</string>
|
<string name="server_config_subtitle">Connect to an alternative FromChat server. This can give you more privacy and control over data.</string>
|
||||||
<string name="server_url_label">Server address</string>
|
<string name="server_ip_label">Server IP or hostname</string>
|
||||||
<string name="server_url_hint">example.com</string>
|
<string name="server_ip_hint">192.168.1.10</string>
|
||||||
<string name="https_enabled">Use a secure connection</string>
|
<string name="api_port_label">Port</string>
|
||||||
|
<string name="api_port_hint">8301</string>
|
||||||
|
<string name="calls_port_label">Calls port</string>
|
||||||
|
<string name="calls_port_hint">8302</string>
|
||||||
|
<string name="https_enabled">Secure connection</string>
|
||||||
|
<string name="server_config_https_hint">Uses HTTPS. Turn off only for plain HTTP.</string>
|
||||||
|
<string name="server_config_https_headline">HTTPS</string>
|
||||||
|
<string name="server_config_https_local_hint">Secure connection. If your server is local, you probably should turn it off.</string>
|
||||||
|
<string name="server_config_host_error">Enter a valid IP or hostname.</string>
|
||||||
|
<string name="server_config_port_error">Enter a port from 1 to 65535.</string>
|
||||||
|
<string name="server_config_snackbar_ok_calls">Server OK. Calls port reachable. Ping: %1$d ms.</string>
|
||||||
|
<string name="server_config_snackbar_ok_calls_skip">Server OK. Calls disabled (no port). Ping: %1$d ms.</string>
|
||||||
|
<string name="server_config_snackbar_api_fail">Cannot reach the API.</string>
|
||||||
|
<string name="server_config_snackbar_calls_fail">Calls port not reachable.</string>
|
||||||
|
<string name="server_config_snackbar_ok_api_calls_bad">Server OK; calls port did not respond.</string>
|
||||||
|
<string name="server_config_snackbar_defaults">Defaults applied.</string>
|
||||||
|
<string name="server_config_fab_verify">Check server</string>
|
||||||
|
<string name="server_config_fab_reset">Reset to defaults</string>
|
||||||
|
<string name="server_config_action_check">Check</string>
|
||||||
|
<string name="server_config_action_reset">Reset</string>
|
||||||
|
<string name="server_config_action_reset_confirm_title">Reset to defaults?</string>
|
||||||
|
<string name="server_config_action_reset_confirm_body">This restores the default server and port settings.</string>
|
||||||
<string name="save_continue">Save and continue</string>
|
<string name="save_continue">Save and continue</string>
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
import ru.fromchat.core.Settings
|
||||||
import ru.fromchat.core.config.Config
|
import ru.fromchat.core.config.Config
|
||||||
import ru.fromchat.ui.chat.PublicChatPanelCache
|
import ru.fromchat.ui.chat.PublicChatPanelCache
|
||||||
import ru.fromchat.ui.dm.DmPanelCache
|
import ru.fromchat.ui.dm.DmPanelCache
|
||||||
@@ -173,6 +174,58 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET-only client: no global 401 handler (used when probing a not-yet-saved server URL). */
|
||||||
|
private val httpProbe by lazy {
|
||||||
|
createPlatformHttpClient {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(json)
|
||||||
|
}
|
||||||
|
install(Logging) {
|
||||||
|
logger = Logger.SIMPLE
|
||||||
|
level = LogLevel.INFO
|
||||||
|
}
|
||||||
|
val currentDevice = currentDeviceInfo()
|
||||||
|
val userAgent = buildLoginUserAgent(
|
||||||
|
osName = currentDevice.osName?.takeIfNotBlank(),
|
||||||
|
osVersion = currentDevice.osVersion?.takeIfNotBlank(),
|
||||||
|
model = currentDevice.model?.takeIfNotBlank(),
|
||||||
|
brand = currentDevice.brand?.takeIfNotBlank(),
|
||||||
|
)
|
||||||
|
defaultRequest {
|
||||||
|
header("User-Agent", userAgent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchServerInstanceId(apiBaseUrl: String): String {
|
||||||
|
val base = apiBaseUrl.trimEnd('/')
|
||||||
|
return httpProbe.get("$base/instance_id").body<ServerInstanceIdResponse>().instanceId.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal TCP/HTTP check: true if a GET succeeds without throwing (any HTTP status). */
|
||||||
|
suspend fun probeHttpGet(url: String): Boolean =
|
||||||
|
runCatching {
|
||||||
|
httpProbe.get(url.trim())
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
suspend fun checkAuthAt(apiBaseUrl: String, bearer: String): Boolean =
|
||||||
|
runCatching {
|
||||||
|
val base = apiBaseUrl.trimEnd('/')
|
||||||
|
httpProbe.get("$base/check_auth") {
|
||||||
|
bearerAuth(bearer)
|
||||||
|
}.body<CheckAuthResponse>().authenticated
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
suspend fun refreshServerInstanceFingerprint() {
|
||||||
|
runCatching {
|
||||||
|
val id = fetchServerInstanceId(Config.apiBaseUrl)
|
||||||
|
if (id.isNotEmpty()) {
|
||||||
|
Settings.lastKnownServerInstanceId = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun String?.takeIfNotBlank(): String? =
|
private fun String?.takeIfNotBlank(): String? =
|
||||||
this?.trim()?.takeIf { it.isNotBlank() }
|
this?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,18 @@ data class SimpleStatusResponse(
|
|||||||
val message: String? = null
|
val message: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ServerInstanceIdResponse(
|
||||||
|
@SerialName("instance_id") val instanceId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CheckAuthResponse(
|
||||||
|
val authenticated: Boolean = false,
|
||||||
|
val username: String? = null,
|
||||||
|
val admin: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MessagesResponse(
|
data class MessagesResponse(
|
||||||
val status: String,
|
val status: String,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package ru.fromchat.calls
|
package ru.fromchat.calls
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -16,10 +19,14 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
@@ -27,12 +34,24 @@ import ru.fromchat.*
|
|||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.ProfileCache
|
import ru.fromchat.api.ProfileCache
|
||||||
import ru.fromchat.api.visibleDisplayName
|
import ru.fromchat.api.visibleDisplayName
|
||||||
|
import ru.fromchat.ui.LocalSystemBarsVisibility
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CallOverlay(modifier: Modifier = Modifier) {
|
fun CallOverlay(modifier: Modifier = Modifier) {
|
||||||
val state by CallStore.ui.collectAsState()
|
val state by CallStore.ui.collectAsState()
|
||||||
|
val systemBars = LocalSystemBarsVisibility.current
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
when (state) {
|
||||||
|
is CallUiState.InCall,
|
||||||
|
is CallUiState.Incoming,
|
||||||
|
-> systemBars?.invoke(false)
|
||||||
|
else -> systemBars?.invoke(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
CallUiState.Idle -> return
|
CallUiState.Idle,
|
||||||
|
is CallUiState.Connecting,
|
||||||
|
-> return
|
||||||
is CallUiState.Failed -> {
|
is CallUiState.Failed -> {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { CallStore.dismissFailed() },
|
onDismissRequest = { CallStore.dismissFailed() },
|
||||||
@@ -45,24 +64,6 @@ fun CallOverlay(modifier: Modifier = Modifier) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is CallUiState.Connecting -> {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.55f)),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.call_status_starting),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is CallUiState.Incoming -> {
|
is CallUiState.Incoming -> {
|
||||||
val me = ApiClient.user?.id
|
val me = ApiClient.user?.id
|
||||||
val cached = ProfileCache.get(s.fromUserId)
|
val cached = ProfileCache.get(s.fromUserId)
|
||||||
@@ -104,7 +105,27 @@ fun CallOverlay(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
is CallUiState.InCall -> {
|
is CallUiState.InCall -> {
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
val enter = remember(s.session.roomName) { Animatable(0.88f) }
|
||||||
|
LaunchedEffect(s.session.roomName) {
|
||||||
|
enter.snapTo(0.88f)
|
||||||
|
enter.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMediumLow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = enter.value
|
||||||
|
scaleY = enter.value
|
||||||
|
transformOrigin = TransformOrigin(0.5f, 0.42f)
|
||||||
|
},
|
||||||
|
) {
|
||||||
CallMediaLayer(
|
CallMediaLayer(
|
||||||
connect = s.session,
|
connect = s.session,
|
||||||
showDialingPlaceholder = false,
|
showDialingPlaceholder = false,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import ru.fromchat.api.ProfileCache
|
|||||||
import ru.fromchat.api.WebSocketMessage
|
import ru.fromchat.api.WebSocketMessage
|
||||||
import ru.fromchat.api.visibleDisplayName
|
import ru.fromchat.api.visibleDisplayName
|
||||||
import ru.fromchat.core.Logger
|
import ru.fromchat.core.Logger
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
|
|
||||||
private const val TAG = "CallStore"
|
private const val TAG = "CallStore"
|
||||||
|
|
||||||
@@ -58,7 +59,10 @@ object CallStore {
|
|||||||
|
|
||||||
fun onWebSocketMessage(message: WebSocketMessage) {
|
fun onWebSocketMessage(message: WebSocketMessage) {
|
||||||
if (message.type != "call_signaling") return
|
if (message.type != "call_signaling") return
|
||||||
val data = message.data ?: return
|
val data = message.data ?: run {
|
||||||
|
Logger.w(TAG, "call_signaling: missing data")
|
||||||
|
return
|
||||||
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { handleSignalingPayload(data) }
|
runCatching { handleSignalingPayload(data) }
|
||||||
.onFailure { Logger.e(TAG, "call_signaling handling failed", it) }
|
.onFailure { Logger.e(TAG, "call_signaling handling failed", it) }
|
||||||
@@ -70,6 +74,10 @@ object CallStore {
|
|||||||
val kind = obj["kind"]?.jsonPrimitive?.contentOrNull
|
val kind = obj["kind"]?.jsonPrimitive?.contentOrNull
|
||||||
val fromUserId = obj["fromUserId"]?.jsonPrimitive?.content?.toIntOrNull()
|
val fromUserId = obj["fromUserId"]?.jsonPrimitive?.content?.toIntOrNull()
|
||||||
val currentId = ApiClient.user?.id ?: return
|
val currentId = ApiClient.user?.id ?: return
|
||||||
|
Logger.d(
|
||||||
|
TAG,
|
||||||
|
"call_signaling payload kind=$kind fromUserId=$fromUserId keys=${obj.keys}",
|
||||||
|
)
|
||||||
if (kind != null) {
|
if (kind != null) {
|
||||||
if (fromUserId == null || fromUserId == currentId) return
|
if (fromUserId == null || fromUserId == currentId) return
|
||||||
when (kind) {
|
when (kind) {
|
||||||
@@ -84,8 +92,12 @@ object CallStore {
|
|||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"accept" -> {}
|
"accept" -> {
|
||||||
else -> {}
|
Logger.d(TAG, "call_signaling accept from peer=$fromUserId (LiveKit path)")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Logger.d(TAG, "call_signaling control kind=$kind from=$fromUserId (ignored)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -93,8 +105,13 @@ object CallStore {
|
|||||||
val roomName = obj["roomName"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
val roomName = obj["roomName"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
||||||
if (serverUrl == null || roomName == null || fromUserId == null) return
|
if (serverUrl == null || roomName == null || fromUserId == null) return
|
||||||
if (fromUserId == currentId) return
|
if (fromUserId == currentId) return
|
||||||
|
if (!Config.callsEnabled) {
|
||||||
|
Logger.d(TAG, "call_signaling invite ignored (calls disabled in server config)")
|
||||||
|
return
|
||||||
|
}
|
||||||
val fromUsername = obj["fromUsername"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
val fromUsername = obj["fromUsername"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||||
if (_ui.value is CallUiState.InCall) return
|
if (_ui.value is CallUiState.InCall) return
|
||||||
|
Logger.d(TAG, "call_signaling → Incoming from=$fromUserId room=$roomName")
|
||||||
_ui.value = CallUiState.Incoming(
|
_ui.value = CallUiState.Incoming(
|
||||||
fromUserId = fromUserId,
|
fromUserId = fromUserId,
|
||||||
fromUsername = fromUsername,
|
fromUsername = fromUsername,
|
||||||
@@ -105,15 +122,21 @@ object CallStore {
|
|||||||
|
|
||||||
fun startOutgoingCall(peerUserId: Int) {
|
fun startOutgoingCall(peerUserId: Int) {
|
||||||
if (peerUserId <= 0 || peerUserId == ApiClient.user?.id) return
|
if (peerUserId <= 0 || peerUserId == ApiClient.user?.id) return
|
||||||
|
if (!Config.callsEnabled) {
|
||||||
|
Logger.d(TAG, "startOutgoingCall ignored (calls disabled: set calls port in server settings)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Logger.d(TAG, "startOutgoingCall(peer=$peerUserId)")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_ui.value = CallUiState.Connecting(peerUserId)
|
// Stay on underlying UI until the room is ready; do not block on callee answering.
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
val tok = ApiClient.fetchLiveKitToken(peerUserId, null)
|
val tok = ApiClient.fetchLiveKitToken(peerUserId, null)
|
||||||
ApiClient.sendLiveKitInvite(peerUserId, tok.roomName, tok.serverUrl)
|
val signalUrl = Config.liveKitSignalingWsUrl()
|
||||||
|
ApiClient.sendLiveKitInvite(peerUserId, tok.roomName, signalUrl)
|
||||||
val label = peerLabel(peerUserId)
|
val label = peerLabel(peerUserId)
|
||||||
LiveKitConnectSession(
|
LiveKitConnectSession(
|
||||||
serverUrl = tok.serverUrl,
|
serverUrl = signalUrl,
|
||||||
token = tok.token,
|
token = tok.token,
|
||||||
peerUserId = peerUserId,
|
peerUserId = peerUserId,
|
||||||
peerDisplayName = label,
|
peerDisplayName = label,
|
||||||
@@ -121,6 +144,10 @@ object CallStore {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onSuccess { session ->
|
}.onSuccess { session ->
|
||||||
|
Logger.d(
|
||||||
|
TAG,
|
||||||
|
"startOutgoingCall → InCall peer=${session.peerUserId} room=${session.roomName}",
|
||||||
|
)
|
||||||
_ui.value = CallUiState.InCall(session)
|
_ui.value = CallUiState.InCall(session)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Logger.e(TAG, "startOutgoingCall failed", e)
|
Logger.e(TAG, "startOutgoingCall failed", e)
|
||||||
@@ -133,6 +160,10 @@ object CallStore {
|
|||||||
|
|
||||||
fun acceptIncoming() {
|
fun acceptIncoming() {
|
||||||
val inc = _ui.value as? CallUiState.Incoming ?: return
|
val inc = _ui.value as? CallUiState.Incoming ?: return
|
||||||
|
if (!Config.callsEnabled) {
|
||||||
|
Logger.d(TAG, "acceptIncoming ignored (calls disabled)")
|
||||||
|
return
|
||||||
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
@@ -142,7 +173,7 @@ object CallStore {
|
|||||||
val display =
|
val display =
|
||||||
if (inc.fromUsername.isNotBlank()) inc.fromUsername else label
|
if (inc.fromUsername.isNotBlank()) inc.fromUsername else label
|
||||||
LiveKitConnectSession(
|
LiveKitConnectSession(
|
||||||
serverUrl = tok.serverUrl,
|
serverUrl = Config.liveKitSignalingWsUrl(),
|
||||||
token = tok.token,
|
token = tok.token,
|
||||||
peerUserId = inc.fromUserId,
|
peerUserId = inc.fromUserId,
|
||||||
peerDisplayName = display,
|
peerDisplayName = display,
|
||||||
@@ -150,6 +181,10 @@ object CallStore {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onSuccess { session ->
|
}.onSuccess { session ->
|
||||||
|
Logger.d(
|
||||||
|
TAG,
|
||||||
|
"acceptIncoming → InCall peer=${session.peerUserId} room=${session.roomName}",
|
||||||
|
)
|
||||||
_ui.value = CallUiState.InCall(session)
|
_ui.value = CallUiState.InCall(session)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Logger.e(TAG, "acceptIncoming failed", e)
|
Logger.e(TAG, "acceptIncoming failed", e)
|
||||||
|
|||||||
@@ -12,19 +12,27 @@ import ru.fromchat.api.DeviceSessionInfo
|
|||||||
import ru.fromchat.ui.Theme
|
import ru.fromchat.ui.Theme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server configuration data
|
* Server configuration: [serverIp], HTTP(S) [apiPort], optional [callsPort] (WebRTC / HAProxy; null = calls disabled). First install defaults to fromchat.ru:443 with calls on 8302.
|
||||||
*/
|
*/
|
||||||
data class ServerConfigData(
|
data class ServerConfigData(
|
||||||
val serverUrl: String,
|
val serverIp: String,
|
||||||
val httpsEnabled: Boolean
|
val apiPort: Int,
|
||||||
|
val callsPort: Int?,
|
||||||
|
val httpsEnabled: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
object Settings {
|
object Settings {
|
||||||
private const val MATERIAL_YOU_KEY = "materialYou"
|
private const val MATERIAL_YOU_KEY = "materialYou"
|
||||||
private const val THEME_KEY = "theme"
|
private const val THEME_KEY = "theme"
|
||||||
|
/** Legacy single field "host:port" or hostname — migrated once to [SERVER_IP_KEY] / [API_PORT_KEY]. */
|
||||||
private const val SERVER_URL_KEY = "server_url"
|
private const val SERVER_URL_KEY = "server_url"
|
||||||
|
private const val SERVER_IP_KEY = "server_ip"
|
||||||
|
private const val API_PORT_KEY = "api_port"
|
||||||
|
/** Stored as Int; values ≤ 0 mean unset (calls disabled). */
|
||||||
|
private const val CALLS_PORT_KEY = "calls_port"
|
||||||
private const val HTTPS_ENABLED_KEY = "https_enabled"
|
private const val HTTPS_ENABLED_KEY = "https_enabled"
|
||||||
private const val DEVICE_SESSIONS_CACHE_KEY = "device_sessions_cache_v1"
|
private const val DEVICE_SESSIONS_CACHE_KEY = "device_sessions_cache_v1"
|
||||||
|
private const val LAST_SERVER_INSTANCE_ID_KEY = "last_server_instance_id"
|
||||||
|
|
||||||
private val settings = PlatformSettings()
|
private val settings = PlatformSettings()
|
||||||
private val deviceSessionsJson = Json { ignoreUnknownKeys = true }
|
private val deviceSessionsJson = Json { ignoreUnknownKeys = true }
|
||||||
@@ -33,8 +41,42 @@ object Settings {
|
|||||||
CoroutineScope(Dispatchers.IO).launch(block = block)
|
CoroutineScope(Dispatchers.IO).launch(block = block)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun serverConfigNotInitialized(): Nothing
|
private suspend fun migrateLegacyServerUrlIfNeeded() {
|
||||||
= throw IllegalStateException("Server config not initialized")
|
if (settings.contains(SERVER_IP_KEY)) return
|
||||||
|
val legacy = settings.getString(SERVER_URL_KEY).trim()
|
||||||
|
if (legacy.isEmpty()) return
|
||||||
|
val (host, port) = parseHostPortLegacy(legacy)
|
||||||
|
settings.putString(SERVER_IP_KEY, host)
|
||||||
|
settings.putInt(API_PORT_KEY, port)
|
||||||
|
if (!settings.contains(CALLS_PORT_KEY)) {
|
||||||
|
settings.putInt(CALLS_PORT_KEY, -1)
|
||||||
|
}
|
||||||
|
settings.remove(SERVER_URL_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHostPortLegacy(legacy: String): Pair<String, Int> {
|
||||||
|
val t = legacy.trim()
|
||||||
|
if (t.startsWith("[")) {
|
||||||
|
val end = t.indexOf(']')
|
||||||
|
if (end > 0) {
|
||||||
|
val inside = t.substring(1, end)
|
||||||
|
val rest = t.substring(end + 1)
|
||||||
|
if (rest.startsWith(":")) {
|
||||||
|
val p = rest.removePrefix(":").toIntOrNull() ?: 443
|
||||||
|
return inside to p
|
||||||
|
}
|
||||||
|
return inside to 443
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val idx = t.lastIndexOf(':')
|
||||||
|
if (idx > 0) {
|
||||||
|
val portPart = t.substring(idx + 1)
|
||||||
|
if (portPart.isNotEmpty() && portPart.all { it.isDigit() }) {
|
||||||
|
return t.substring(0, idx) to portPart.toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t to 443
|
||||||
|
}
|
||||||
|
|
||||||
var materialYou: Boolean
|
var materialYou: Boolean
|
||||||
get() = runBlocking { settings.getBoolean(MATERIAL_YOU_KEY, true) }
|
get() = runBlocking { settings.getBoolean(MATERIAL_YOU_KEY, true) }
|
||||||
@@ -44,44 +86,51 @@ object Settings {
|
|||||||
get() = runBlocking { Theme.entries[settings.getInt(THEME_KEY, Theme.AsSystem.ordinal)] }
|
get() = runBlocking { Theme.entries[settings.getInt(THEME_KEY, Theme.AsSystem.ordinal)] }
|
||||||
set(value) = runIO { settings.putInt(THEME_KEY, value.ordinal) }
|
set(value) = runIO { settings.putInt(THEME_KEY, value.ordinal) }
|
||||||
|
|
||||||
var serverUrl: String
|
var httpsEnabled: Boolean
|
||||||
get() = runBlocking {
|
get() = runBlocking {
|
||||||
settings.getString(SERVER_URL_KEY).ifEmpty {
|
if (settings.contains(HTTPS_ENABLED_KEY)) {
|
||||||
|
settings.getBoolean(HTTPS_ENABLED_KEY, true)
|
||||||
|
} else {
|
||||||
serverConfigNotInitialized()
|
serverConfigNotInitialized()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
set(value) = runIO { settings.putString(SERVER_URL_KEY, value) }
|
|
||||||
|
|
||||||
var httpsEnabled: Boolean
|
|
||||||
get() = runBlocking {
|
|
||||||
if (settings.contains(HTTPS_ENABLED_KEY))
|
|
||||||
settings.getBoolean(HTTPS_ENABLED_KEY, true)
|
|
||||||
else serverConfigNotInitialized()
|
|
||||||
}
|
|
||||||
set(value) = runIO { settings.putBoolean(HTTPS_ENABLED_KEY, value) }
|
set(value) = runIO { settings.putBoolean(HTTPS_ENABLED_KEY, value) }
|
||||||
|
|
||||||
val hasServerConfig get() = try {
|
private fun serverConfigNotInitialized(): Nothing =
|
||||||
serverUrl
|
throw IllegalStateException("Server config not initialized")
|
||||||
httpsEnabled
|
|
||||||
true
|
|
||||||
} catch (_: IllegalStateException) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
var serverConfig: ServerConfigData
|
var serverConfig: ServerConfigData
|
||||||
get() {
|
get() = runBlocking {
|
||||||
if (!hasServerConfig) {
|
migrateLegacyServerUrlIfNeeded()
|
||||||
serverUrl = "fromchat.ru"
|
if (!settings.contains(SERVER_IP_KEY)) {
|
||||||
httpsEnabled = true
|
settings.putString(SERVER_IP_KEY, "fromchat.ru")
|
||||||
|
settings.putInt(API_PORT_KEY, 443)
|
||||||
return ServerConfigData("fromchat.ru", true)
|
settings.putInt(CALLS_PORT_KEY, 8302)
|
||||||
|
if (!settings.contains(HTTPS_ENABLED_KEY)) {
|
||||||
|
settings.putBoolean(HTTPS_ENABLED_KEY, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
val ip = settings.getString(SERVER_IP_KEY)
|
||||||
return ServerConfigData(serverUrl, httpsEnabled)
|
if (ip.isBlank()) {
|
||||||
|
serverConfigNotInitialized()
|
||||||
|
}
|
||||||
|
val apiPort = settings.getInt(API_PORT_KEY, 443).coerceIn(1, 65535)
|
||||||
|
val rawCalls = settings.getInt(CALLS_PORT_KEY, -1)
|
||||||
|
val calls = if (rawCalls > 0 && rawCalls <= 65535) rawCalls else null
|
||||||
|
val https = settings.getBoolean(HTTPS_ENABLED_KEY, true)
|
||||||
|
ServerConfigData(
|
||||||
|
serverIp = ip,
|
||||||
|
apiPort = apiPort,
|
||||||
|
callsPort = calls,
|
||||||
|
httpsEnabled = https,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
set(value) {
|
set(value) = runIO {
|
||||||
serverUrl = value.serverUrl
|
settings.putString(SERVER_IP_KEY, value.serverIp.trim())
|
||||||
httpsEnabled = value.httpsEnabled
|
settings.putInt(API_PORT_KEY, value.apiPort.coerceIn(1, 65535))
|
||||||
|
val callsStored = value.callsPort?.coerceIn(1, 65535) ?: -1
|
||||||
|
settings.putInt(CALLS_PORT_KEY, callsStored)
|
||||||
|
settings.putBoolean(HTTPS_ENABLED_KEY, value.httpsEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cached device sessions (JSON). Shown immediately while refreshing from the network. */
|
/** Cached device sessions (JSON). Shown immediately while refreshing from the network. */
|
||||||
@@ -91,7 +140,7 @@ object Settings {
|
|||||||
runCatching {
|
runCatching {
|
||||||
deviceSessionsJson.decodeFromString(
|
deviceSessionsJson.decodeFromString(
|
||||||
ListSerializer(DeviceSessionInfo.serializer()),
|
ListSerializer(DeviceSessionInfo.serializer()),
|
||||||
raw
|
raw,
|
||||||
)
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
@@ -100,7 +149,7 @@ object Settings {
|
|||||||
runIO {
|
runIO {
|
||||||
val enc = deviceSessionsJson.encodeToString(
|
val enc = deviceSessionsJson.encodeToString(
|
||||||
ListSerializer(DeviceSessionInfo.serializer()),
|
ListSerializer(DeviceSessionInfo.serializer()),
|
||||||
list
|
list,
|
||||||
)
|
)
|
||||||
settings.putString(DEVICE_SESSIONS_CACHE_KEY, enc)
|
settings.putString(DEVICE_SESSIONS_CACHE_KEY, enc)
|
||||||
}
|
}
|
||||||
@@ -109,5 +158,9 @@ object Settings {
|
|||||||
fun clearDeviceSessionsCache() {
|
fun clearDeviceSessionsCache() {
|
||||||
runIO { settings.remove(DEVICE_SESSIONS_CACHE_KEY) }
|
runIO { settings.remove(DEVICE_SESSIONS_CACHE_KEY) }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
/** Last known [ServerInstanceIdResponse.instanceId] from the configured API (empty until first fetch). */
|
||||||
|
var lastKnownServerInstanceId: String
|
||||||
|
get() = runBlocking { settings.getString(LAST_SERVER_INSTANCE_ID_KEY, "") }
|
||||||
|
set(value) = runIO { settings.putString(LAST_SERVER_INSTANCE_ID_KEY, value) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,19 +13,20 @@ object Config {
|
|||||||
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
|
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
|
||||||
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
|
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
|
||||||
|
|
||||||
private val config: ServerConfigData get() {
|
private val config: ServerConfigData
|
||||||
if (_serverConfig.value == null) initialize()
|
get() {
|
||||||
|
if (_serverConfig.value == null) initialize()
|
||||||
|
return _serverConfig.value
|
||||||
|
?: throw IllegalStateException("Server configuration not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
return (_serverConfig.value ?: IllegalStateException("Server configuration not initialized")) as ServerConfigData
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize configuration by loading from storage
|
* Initialize configuration by loading from storage
|
||||||
*/
|
*/
|
||||||
fun initialize() {
|
fun initialize() {
|
||||||
_serverConfig.value = Settings.serverConfig
|
_serverConfig.value = Settings.serverConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update server configuration
|
* Update server configuration
|
||||||
*/
|
*/
|
||||||
@@ -33,16 +34,35 @@ object Config {
|
|||||||
Settings.serverConfig = config
|
Settings.serverConfig = config
|
||||||
_serverConfig.value = config
|
_serverConfig.value = config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Voice/video calls (LiveKit) are enabled only when a calls (WebRTC) port is configured. */
|
||||||
|
val callsEnabled: Boolean
|
||||||
|
get() = config.callsPort != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LiveKit signaling WebSocket URL: same host and API port as HTTPS reverse proxy (`/api/livekit/rtc`).
|
||||||
|
* WebRTC media uses the configured calls port on the server (e.g. HAProxy in front of LiveKit).
|
||||||
|
*/
|
||||||
|
fun liveKitSignalingWsUrl(): String {
|
||||||
|
val scheme = if (config.httpsEnabled) "wss" else "ws"
|
||||||
|
return "$scheme://${config.serverIp}:${config.apiPort}/api/livekit/rtc"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get API base URL based on current server configuration
|
* Get API base URL based on current server configuration
|
||||||
*/
|
*/
|
||||||
val apiBaseUrl
|
val apiBaseUrl: String
|
||||||
get() = "${if (config.httpsEnabled) "https" else "http"}://${config.serverUrl}/api"
|
get() {
|
||||||
|
val scheme = if (config.httpsEnabled) "https" else "http"
|
||||||
|
return "$scheme://${config.serverIp}:${config.apiPort}/api"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get WebSocket URL based on current server configuration
|
* Get WebSocket URL for app chat signaling (proxied on the same port as HTTPS / API).
|
||||||
*/
|
*/
|
||||||
val webSocketUrl
|
val webSocketUrl: String
|
||||||
get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws"
|
get() {
|
||||||
|
val scheme = if (config.httpsEnabled) "wss" else "ws"
|
||||||
|
return "$scheme://${config.serverIp}:${config.apiPort}/api/chat/ws"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.
|
|||||||
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
|
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
|
||||||
import androidx.compose.animation.SharedTransitionLayout
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -332,7 +333,7 @@ fun App(
|
|||||||
LocalSystemBarsVisibility provides rememberSystemBarsController()
|
LocalSystemBarsVisibility provides rememberSystemBarsController()
|
||||||
) {
|
) {
|
||||||
if (startDestination != null) {
|
if (startDestination != null) {
|
||||||
val animationSpec = tween<IntOffset>(400)
|
val rootNavMotion = spring<IntOffset>(dampingRatio = 0.88f, stiffness = 420f)
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -340,25 +341,25 @@ fun App(
|
|||||||
enterTransition = {
|
enterTransition = {
|
||||||
slideIntoContainer(
|
slideIntoContainer(
|
||||||
Start,
|
Start,
|
||||||
animationSpec = animationSpec
|
animationSpec = rootNavMotion
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
exitTransition = {
|
exitTransition = {
|
||||||
slideOutOfContainer(
|
slideOutOfContainer(
|
||||||
Start,
|
Start,
|
||||||
animationSpec = animationSpec
|
animationSpec = rootNavMotion
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
popEnterTransition = {
|
popEnterTransition = {
|
||||||
slideIntoContainer(
|
slideIntoContainer(
|
||||||
End,
|
End,
|
||||||
animationSpec = animationSpec
|
animationSpec = rootNavMotion
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
popExitTransition = {
|
popExitTransition = {
|
||||||
slideOutOfContainer(
|
slideOutOfContainer(
|
||||||
End,
|
End,
|
||||||
animationSpec = animationSpec
|
animationSpec = rootNavMotion
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
@@ -511,25 +512,25 @@ fun App(
|
|||||||
enterTransition = {
|
enterTransition = {
|
||||||
when (initialState.destination.route) {
|
when (initialState.destination.route) {
|
||||||
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
|
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
|
||||||
else -> slideIntoContainer(Start, animationSpec = animationSpec)
|
else -> slideIntoContainer(Start, animationSpec = rootNavMotion)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
exitTransition = {
|
exitTransition = {
|
||||||
when (targetState.destination.route) {
|
when (targetState.destination.route) {
|
||||||
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
|
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
|
||||||
else -> slideOutOfContainer(Start, animationSpec = animationSpec)
|
else -> slideOutOfContainer(Start, animationSpec = rootNavMotion)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
popEnterTransition = {
|
popEnterTransition = {
|
||||||
when (initialState.destination.route) {
|
when (initialState.destination.route) {
|
||||||
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
|
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
|
||||||
else -> slideIntoContainer(End, animationSpec = animationSpec)
|
else -> slideIntoContainer(End, animationSpec = rootNavMotion)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
popExitTransition = {
|
popExitTransition = {
|
||||||
when (targetState.destination.route) {
|
when (targetState.destination.route) {
|
||||||
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
|
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
|
||||||
else -> slideOutOfContainer(End, animationSpec = animationSpec)
|
else -> slideOutOfContainer(End, animationSpec = rootNavMotion)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { entry ->
|
) { entry ->
|
||||||
@@ -563,7 +564,7 @@ fun App(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
settingsSlideComposable("about", animationSpec) {
|
settingsSlideComposable("about", rootNavMotion) {
|
||||||
AboutScreen()
|
AboutScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,28 +574,28 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
settingsSlideComposable(SettingsRoutes.Appearance, animationSpec) {
|
settingsSlideComposable(SettingsRoutes.Appearance, rootNavMotion) {
|
||||||
SettingsAppearanceScreen(onBack = { navController.navigateUp() })
|
SettingsAppearanceScreen(onBack = { navController.navigateUp() })
|
||||||
}
|
}
|
||||||
settingsSlideComposable(SettingsRoutes.ServerTools, animationSpec) {
|
settingsSlideComposable(SettingsRoutes.ServerTools, rootNavMotion) {
|
||||||
SettingsServerToolsScreen(
|
SettingsServerToolsScreen(
|
||||||
onBack = { navController.navigateUp() },
|
onBack = { navController.navigateUp() },
|
||||||
outerNav = navController
|
outerNav = navController
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
settingsSlideComposable(SettingsRoutes.Notifications, animationSpec) {
|
settingsSlideComposable(SettingsRoutes.Notifications, rootNavMotion) {
|
||||||
SettingsNotificationsScreen(onBack = { navController.navigateUp() })
|
SettingsNotificationsScreen(onBack = { navController.navigateUp() })
|
||||||
}
|
}
|
||||||
settingsSlideComposable(SettingsRoutes.Devices, animationSpec) {
|
settingsSlideComposable(SettingsRoutes.Devices, rootNavMotion) {
|
||||||
SettingsDevicesScreen(onBack = { navController.navigateUp() })
|
SettingsDevicesScreen(onBack = { navController.navigateUp() })
|
||||||
}
|
}
|
||||||
settingsSlideComposable(SettingsRoutes.Security, animationSpec) {
|
settingsSlideComposable(SettingsRoutes.Security, rootNavMotion) {
|
||||||
SettingsSecurityHubScreen(
|
SettingsSecurityHubScreen(
|
||||||
onBack = { navController.navigateUp() },
|
onBack = { navController.navigateUp() },
|
||||||
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) }
|
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, animationSpec) {
|
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, rootNavMotion) {
|
||||||
SettingsSecurityPasswordFlowScreen(
|
SettingsSecurityPasswordFlowScreen(
|
||||||
onBack = { navController.navigateUp() },
|
onBack = { navController.navigateUp() },
|
||||||
onDonePopToHub = {
|
onDonePopToHub = {
|
||||||
@@ -602,7 +603,7 @@ fun App(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
settingsSlideComposable(SettingsRoutes.Account, animationSpec) {
|
settingsSlideComposable(SettingsRoutes.Account, rootNavMotion) {
|
||||||
SettingsAccountScreen(
|
SettingsAccountScreen(
|
||||||
onBack = { navController.navigateUp() },
|
onBack = { navController.navigateUp() },
|
||||||
onLogout = navigateToLoginClearingChat,
|
onLogout = navigateToLoginClearingChat,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package ru.fromchat.ui
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Snackbar
|
||||||
|
import androidx.compose.material3.SnackbarDefaults
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide snackbar styling: elevated surface container instead of inverse surface.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FromChatSnackbarHost(
|
||||||
|
hostState: SnackbarHostState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
snackbarModifier: Modifier = Modifier,
|
||||||
|
shape: Shape = SnackbarDefaults.shape,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = hostState,
|
||||||
|
modifier = modifier,
|
||||||
|
) { data ->
|
||||||
|
Snackbar(
|
||||||
|
snackbarData = data,
|
||||||
|
modifier = snackbarModifier,
|
||||||
|
shape = shape,
|
||||||
|
containerColor = scheme.surfaceContainerHigh,
|
||||||
|
contentColor = scheme.onSurface,
|
||||||
|
actionColor = scheme.primary,
|
||||||
|
actionContentColor = scheme.primary,
|
||||||
|
dismissActionContentColor = scheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -194,6 +194,7 @@ fun LoginScreen(
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
ApiClient.persistSessionToStorage(response)
|
ApiClient.persistSessionToStorage(response)
|
||||||
|
runCatching { ApiClient.refreshServerInstanceFingerprint() }
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ fun RegisterScreen(
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
ApiClient.persistSessionToStorage(response)
|
ApiClient.persistSessionToStorage(response)
|
||||||
|
runCatching { ApiClient.refreshServerInstanceFingerprint() }
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,6 +286,12 @@ fun ChatScreen(
|
|||||||
panel.handleWebSocketMessage(message)
|
panel.handleWebSocketMessage(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"call_signaling" -> {
|
||||||
|
Logger.d(
|
||||||
|
"ChatScreen",
|
||||||
|
"call_signaling (also dispatched to App global → CallStore); skipping panel",
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Logger.d("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}")
|
Logger.d("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import ru.fromchat.api.WebSocketMessage
|
|||||||
import ru.fromchat.api.db.MessageCacheStore
|
import ru.fromchat.api.db.MessageCacheStore
|
||||||
import ru.fromchat.api.visibleDisplayName
|
import ru.fromchat.api.visibleDisplayName
|
||||||
import ru.fromchat.core.Logger
|
import ru.fromchat.core.Logger
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
import ru.fromchat.crypto.CorruptedDmMessagePlaceholder
|
import ru.fromchat.crypto.CorruptedDmMessagePlaceholder
|
||||||
import ru.fromchat.crypto.DmCiphertextCorruptedException
|
import ru.fromchat.crypto.DmCiphertextCorruptedException
|
||||||
import ru.fromchat.crypto.decryptEnvelope
|
import ru.fromchat.crypto.decryptEnvelope
|
||||||
@@ -418,7 +419,7 @@ class DmPanel(
|
|||||||
|
|
||||||
override suspend fun handleDeleteMessage(messageId: Int) {}
|
override suspend fun handleDeleteMessage(messageId: Int) {}
|
||||||
|
|
||||||
override fun showCallButton(): Boolean = true
|
override fun showCallButton(): Boolean = Config.callsEnabled
|
||||||
|
|
||||||
override fun getTypingHandler(): TypingHandler = typingHandler
|
override fun getTypingHandler(): TypingHandler = typingHandler
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import androidx.compose.material3.NavigationBarItem
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -32,6 +31,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.*
|
import ru.fromchat.*
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.ui.FromChatSnackbarHost
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.utils.exclude
|
import ru.fromchat.utils.exclude
|
||||||
import ru.fromchat.ui.main.settings.SettingsTab
|
import ru.fromchat.ui.main.settings.SettingsTab
|
||||||
@@ -65,7 +65,7 @@ fun MainScreen(
|
|||||||
val isChatsPage = selectedPage == PAGE_CHATS
|
val isChatsPage = selectedPage == PAGE_CHATS
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(hostState = effectiveSnackbarHostState) },
|
snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) },
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
NavigationBar {
|
NavigationBar {
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialShapes
|
import androidx.compose.material3.MaterialShapes
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.toPath
|
import androidx.compose.material3.toPath
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -260,8 +261,10 @@ fun SettingsSecurityMorphedPasswordHero(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Large icon in a rounded shape for empty states and smaller heroes.
|
* Large icon clipped with a Material expressive polygon ([MaterialShapes] + [toShape]).
|
||||||
|
* Default is a circle; pass another library polygon (e.g. cookie-sided) to match adjacent flows.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsExpressiveIconFrame(
|
fun SettingsExpressiveIconFrame(
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
@@ -269,12 +272,14 @@ fun SettingsExpressiveIconFrame(
|
|||||||
containerSize: Dp = 112.dp,
|
containerSize: Dp = 112.dp,
|
||||||
iconSize: Dp = 52.dp,
|
iconSize: Dp = 52.dp,
|
||||||
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
|
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
contentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer
|
contentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
materialPolygon: RoundedPolygon = MaterialShapes.Circle,
|
||||||
) {
|
) {
|
||||||
|
val frameShape = materialPolygon.normalized().toShape()
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(containerSize)
|
.size(containerSize)
|
||||||
.clip(RoundedCornerShape(percent = 32))
|
.clip(frameShape)
|
||||||
.background(containerColor),
|
.background(containerColor),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -82,10 +82,8 @@ import androidx.compose.material3.MediumTopAppBar
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.Snackbar
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -115,6 +113,7 @@ import androidx.compose.ui.graphics.ColorFilter
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import ru.fromchat.ui.FromChatSnackbarHost
|
||||||
import com.pr0gramm3r101.components.Category
|
import com.pr0gramm3r101.components.Category
|
||||||
import com.pr0gramm3r101.components.ListItem
|
import com.pr0gramm3r101.components.ListItem
|
||||||
import com.pr0gramm3r101.components.SwitchListItem
|
import com.pr0gramm3r101.components.SwitchListItem
|
||||||
@@ -577,15 +576,7 @@ fun SettingsNotificationsScreen(onBack: () -> Unit) {
|
|||||||
val unexpectedErrorText = stringResource(Res.string.error_unexpected)
|
val unexpectedErrorText = stringResource(Res.string.error_unexpected)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = {
|
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
||||||
SnackbarHost(hostState = snackbarHostState) {
|
|
||||||
Snackbar(
|
|
||||||
snackbarData = it,
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
@@ -1128,7 +1119,7 @@ fun SettingsDevicesScreen(onBack: () -> Unit) {
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
title = { Text(stringResource(Res.string.settings_devices_title)) },
|
title = { Text(stringResource(Res.string.settings_devices_title)) },
|
||||||
@@ -1763,22 +1754,15 @@ fun SettingsSecurityPasswordFlowScreen(onBack: () -> Unit, onDonePopToHub: () ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SnackbarHost(
|
FromChatSnackbarHost(
|
||||||
hostState = snackbarHostState,
|
hostState = snackbarHostState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||||
.padding(bottom = 76.dp)
|
.padding(bottom = 76.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
snackbar = { data ->
|
snackbarModifier = Modifier.fillMaxWidth(),
|
||||||
Snackbar(
|
shape = RoundedCornerShape(16.dp),
|
||||||
snackbarData = data,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1909,7 +1893,7 @@ fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePass
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
title = { Text(stringResource(Res.string.settings_account_title)) },
|
title = { Text(stringResource(Res.string.settings_account_title)) },
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package ru.fromchat.ui.setup
|
||||||
|
|
||||||
|
private fun isAllowedHostChar(ch: Char): Boolean =
|
||||||
|
ch.isLetterOrDigit() || ch in ".:-[]_"
|
||||||
|
|
||||||
|
internal fun filterHostInput(raw: String): String =
|
||||||
|
raw.filter(::isAllowedHostChar).take(253)
|
||||||
|
|
||||||
|
internal fun isValidPortNumber(text: String): Boolean {
|
||||||
|
if (text.isBlank()) return true
|
||||||
|
val n = text.toIntOrNull() ?: return false
|
||||||
|
return n in 1..65535
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isValidIpOrHostname(host: String): Boolean {
|
||||||
|
val h = host.trim()
|
||||||
|
if (h.isEmpty() || h.length > 253) return false
|
||||||
|
if (h.any { !isAllowedHostChar(it) }) return false
|
||||||
|
return isValidIpv4(h) || isValidIpv6Bracketed(h) || isValidHostname(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidIpv4(s: String): Boolean {
|
||||||
|
val p = s.split('.')
|
||||||
|
if (p.size != 4) return false
|
||||||
|
for (x in p) {
|
||||||
|
val n = x.toIntOrNull() ?: return false
|
||||||
|
if (n !in 0..255) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidIpv6Bracketed(s: String): Boolean {
|
||||||
|
if (!s.startsWith('[') || !s.contains(']')) return false
|
||||||
|
val end = s.indexOf(']')
|
||||||
|
if (end <= 1) return false
|
||||||
|
val inner = s.substring(1, end)
|
||||||
|
if (inner.isBlank()) return false
|
||||||
|
if (!inner.all { it.isDigit() || it in "abcdefABCDEF:" }) return false
|
||||||
|
val colons = inner.count { it == ':' }
|
||||||
|
return colons >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidHostname(host: String): Boolean {
|
||||||
|
if (host.contains(':')) return false
|
||||||
|
if (host.startsWith('[')) return false
|
||||||
|
if (host.endsWith('.')) return false
|
||||||
|
val labels = host.split('.')
|
||||||
|
if (labels.isEmpty() || labels.size > 127) return false
|
||||||
|
for (label in labels) {
|
||||||
|
if (label.isEmpty() || label.length > 63) return false
|
||||||
|
if (label.startsWith('-') || label.endsWith('-')) return false
|
||||||
|
if (!label.all { it.isLetterOrDigit() || it == '-' || it == '_' }) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host part for URL authority (brackets IPv6). */
|
||||||
|
internal fun hostForAuthority(host: String): String {
|
||||||
|
val t = host.trim()
|
||||||
|
if (t.contains(':') && !t.startsWith("[")) return "[$t]"
|
||||||
|
return t
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user