diff --git a/.cursor/rules/android-build-deploy-device.mdc b/.cursor/rules/android-build-deploy-device.mdc deleted file mode 100644 index c8837b9..0000000 --- a/.cursor/rules/android-build-deploy-device.mdc +++ /dev/null @@ -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. diff --git a/.cursor/rules/android.mdc b/.cursor/rules/android.mdc new file mode 100644 index 0000000..d5aae4d --- /dev/null +++ b/.cursor/rules/android.mdc @@ -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. `/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 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. diff --git a/.cursor/rules/debug-logging-ktor.mdc b/.cursor/rules/debug-logging-ktor.mdc deleted file mode 100644 index 1547ce4..0000000 --- a/.cursor/rules/debug-logging-ktor.mdc +++ /dev/null @@ -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. - diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc deleted file mode 100644 index 175cdce..0000000 --- a/.cursor/rules/general.mdc +++ /dev/null @@ -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 \ No newline at end of file diff --git a/.cursor/rules/no-gradle-cache-grep.mdc b/.cursor/rules/no-gradle-cache-grep.mdc deleted file mode 100644 index 09e0a93..0000000 --- a/.cursor/rules/no-gradle-cache-grep.mdc +++ /dev/null @@ -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. diff --git a/.cursor/rules/no-placeholder-string-sanitization.mdc b/.cursor/rules/no-placeholder-string-sanitization.mdc deleted file mode 100644 index ff61612..0000000 --- a/.cursor/rules/no-placeholder-string-sanitization.mdc +++ /dev/null @@ -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. diff --git a/.cursor/rules/strings-simple-i18n.mdc b/.cursor/rules/strings-simple-i18n.mdc deleted file mode 100644 index fed68f4..0000000 --- a/.cursor/rules/strings-simple-i18n.mdc +++ /dev/null @@ -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. diff --git a/.cursor/rules/utils-preference.mdc b/.cursor/rules/utils-preference.mdc deleted file mode 100644 index 6b52fa5..0000000 --- a/.cursor/rules/utils-preference.mdc +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index 6381bba..6c4b9a3 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ app/android/debug xcuserdata google-services.json -.cursor/plans \ No newline at end of file +.cursor/plans +*.log \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 7643783..9c3d95a 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,5 +1,39 @@ + + + diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml index 5cc166f..a9fd61c 100644 --- a/app/android/src/main/AndroidManifest.xml +++ b/app/android/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + ( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, +) + private val REQUIRED_PERMISSIONS = arrayOf( Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA, ) private enum class VideoSlot { + None, RemoteScreen, RemoteCam, LocalScreen, LocalCam, } +private data class CallOwnerUi( + val name: String, + val avatarName: String, + val pictureUrl: String?, + val level: Float, + val isSelf: Boolean, +) + private tailrec fun findActivity(ctx: Context?): Activity? = when (ctx) { is Activity -> ctx is ContextWrapper -> findActivity(ctx.baseContext) else -> null } +@Composable +private fun StreamOwnerChip( + label: String, + avatarName: String, + profilePictureUrl: String?, + audioLevel: Float, + showName: Boolean, + modifier: Modifier = Modifier, +) { + val cap = 0.55f + val boost = (audioLevel.coerceIn(0f, cap) / cap).coerceIn(0f, 1f) + val scale = 1f + boost * 0.12f + Row( + modifier = modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.9f)) + .padding( + horizontal = if (showName) 6.dp else 4.dp, + vertical = 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(if (showName) 6.dp else 0.dp), + ) { + Box( + Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) { + Avatar( + profilePictureUrl = profilePictureUrl, + displayName = avatarName, + modifier = Modifier + .size(if (showName) 26.dp else 24.dp) + .clip(CircleShape), + ) + } + if (showName && label.isNotBlank()) { + Text( + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.widthIn(max = 140.dp), + ) + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun CallSlotSharedVideo( + room: Room, + sharedTransitionScope: SharedTransitionScope, + slot: VideoSlot, + trackRef: TrackReference, + mirror: Boolean, + scaleType: ScaleType, + modifier: Modifier, + innerClipShape: Shape?, +) { + with(sharedTransitionScope) { + val state = rememberSharedContentState(key = slot) + Box( + modifier + .sharedElementWithCallerManagedVisibility( + sharedContentState = state, + visible = true, + boundsTransform = { _, _ -> + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + }, + ), + ) { + VideoTrackView( + trackReference = trackRef, + modifier = Modifier + .fillMaxSize() + .then( + if (innerClipShape != null) Modifier.clip(innerClipShape) else Modifier, + ), + room = room, + mirror = mirror, + scaleType = scaleType, + ) + } + } +} + @OptIn(ExperimentalHazeMaterialsApi::class) @Composable actual fun CallMediaLayer( @@ -160,20 +275,34 @@ actual fun CallMediaLayer( ) } var askedForPermissions by remember { mutableStateOf(false) } + var micRequestedOn by remember(connect?.roomName) { mutableStateOf(true) } val launcher = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), ) { result -> permissionsGranted = result.values.all { it } + Logger.d(TAG, "media permissions result=$permissionsGranted detail=$result") } LaunchedEffect(connect, showDialingPlaceholder) { val needMedia = connect != null || showDialingPlaceholder + Logger.d( + TAG, + "CallMediaLayer LaunchedEffect(connect,placeholder): needMedia=$needMedia " + + "connectRoom=${connect?.roomName} perms=$permissionsGranted asked=$askedForPermissions", + ) if (needMedia && !permissionsGranted && !askedForPermissions) { askedForPermissions = true launcher.launch(REQUIRED_PERMISSIONS) } } + LaunchedEffect(connect?.roomName, permissionsGranted, micRequestedOn) { + Logger.d( + TAG, + "CallMediaLayer snapshot room=${connect?.roomName} perms=$permissionsGranted micReq=$micRequestedOn", + ) + } + when { connect == null && showDialingPlaceholder -> { Box( @@ -186,25 +315,76 @@ actual fun CallMediaLayer( } } connect != null && permissionsGranted -> { + Logger.d( + TAG, + "RoomScope starting url=${connect.serverUrl} room=${connect.roomName} " + + "(mic UI sync waits for CONNECTED; DISCONNECTED means join failed or network)", + ) RoomScope( url = connect.serverUrl, token = connect.token, - audio = true, - video = true, + roomOptions = RoomOptions( + audioTrackCaptureDefaults = LocalAudioTrackOptions( + typingNoiseDetection = false, + ), + ), + audio = micRequestedOn, + video = false, + onError = { r, e -> + Logger.e( + TAG, + "RoomScope onError state=${r.state} msg=${e?.message}", + e, + ) + }, + onDisconnected = { r -> + Logger.w(TAG, "RoomScope onDisconnected state=${r.state}") + }, onConnected = { room -> - launch { - delay(200) + Logger.d( + TAG, + "RoomScope onConnected state=${room.state} micReq=$micRequestedOn " + + "micEn=${room.localParticipant.isMicrophoneEnabled}", + ) + withContext(NonCancellable) { runCatching { - room.localParticipant.setMicrophoneEnabled(true) - room.localParticipant.setCameraEnabled(true) + room.localParticipant.setCameraEnabled(false) }.onFailure { Logger.e(TAG, "onConnected media enable failed", it) } } + Logger.d( + TAG, + "RoomScope onConnected after camera off: micEn=${room.localParticipant.isMicrophoneEnabled}", + ) }, ) { room -> + LaunchedEffect(room) { + room.events.collect { event: RoomEvent -> + when (event) { + is RoomEvent.Connected -> + Logger.d(TAG, "RoomEvent.Connected") + is RoomEvent.Disconnected -> + Logger.w( + TAG, + "RoomEvent.Disconnected reason=${event.reason} " + + "err=${event.error?.message}", + event.error, + ) + is RoomEvent.FailedToConnect -> + Logger.e(TAG, "RoomEvent.FailedToConnect", event.error) + is RoomEvent.Reconnecting -> + Logger.d(TAG, "RoomEvent.Reconnecting") + is RoomEvent.Reconnected -> + Logger.d(TAG, "RoomEvent.Reconnected") + else -> {} + } + } + } CallRoomContent( room = room, session = connect, showInCallControls = showInCallControls, + micRequestedOn = micRequestedOn, + onMicRequestedChange = { micRequestedOn = it }, modifier = modifier, ) } @@ -235,6 +415,8 @@ private fun CallRoomContent( room: Room, session: LiveKitConnectSession, showInCallControls: Boolean, + micRequestedOn: Boolean, + onMicRequestedChange: (Boolean) -> Unit, modifier: Modifier, ) { val context = LocalContext.current @@ -242,12 +424,18 @@ private fun CallRoomContent( val ongoingTitle = stringResource(Res.string.notif_call_ongoing_title) val ongoingText = stringResource(Res.string.notif_call_ongoing_text) val callStartWallMs = remember(session.roomName) { System.currentTimeMillis() } + DisposableEffect(Unit) { - val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val previous = am.mode - am.mode = AudioManager.MODE_IN_COMMUNICATION + val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager + val tag = "${TAG}:CallWake" + val wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag).apply { + setReferenceCounted(false) + acquire(6 * 60 * 60 * 1000L) + } onDispose { - am.mode = previous + runCatching { + if (wakeLock.isHeld) wakeLock.release() + } } } @@ -265,9 +453,70 @@ private fun CallRoomContent( } } + LaunchedEffect(room, micRequestedOn) { + Logger.d( + TAG, + "micSync effect START micReq=$micRequestedOn roomState=${room.state} " + + "micEn=${room.localParticipant.isMicrophoneEnabled}", + ) + var lastLoggedRoomState = room.state + while (isActive) { + val state = room.state + if (state != lastLoggedRoomState) { + Logger.d( + TAG, + "micSync room.state $lastLoggedRoomState → $state micReq=$micRequestedOn " + + "micEn=${room.localParticipant.isMicrophoneEnabled}", + ) + lastLoggedRoomState = state + } + if (state != Room.State.CONNECTED) { + delay(300) + continue + } + + val current = room.localParticipant.isMicrophoneEnabled + if (current != micRequestedOn) { + Logger.d(TAG, "micSync calling setMicrophoneEnabled($micRequestedOn) current=$current") + val ok = runCatching { + room.localParticipant.setMicrophoneEnabled(micRequestedOn) + }.onFailure { e -> + Logger.e(TAG, "setMicrophoneEnabled($micRequestedOn) threw", e) + }.getOrDefault(false) + + if (!ok) { + Logger.w(TAG, "setMicrophoneEnabled($micRequestedOn) returned false") + } + Logger.d( + TAG, + "micSync after setMicrophoneEnabled: wanted=$micRequestedOn " + + "micEn=${room.localParticipant.isMicrophoneEnabled} ok=$ok", + ) + } + + if (!micRequestedOn || room.localParticipant.isMicrophoneEnabled) { + Logger.d( + TAG, + "micSync effect DONE micReq=$micRequestedOn micEn=${room.localParticipant.isMicrophoneEnabled}", + ) + break + } + + Logger.w( + TAG, + "micSync mismatch after apply; retry in 800ms micReq=$micRequestedOn " + + "micEn=${room.localParticipant.isMicrophoneEnabled}", + ) + delay(800) + } + } + val hazeState = rememberHazeState(blurEnabled = showInCallControls) - val navBottomDp = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - Box(modifier = modifier.fillMaxSize()) { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + ) { Box( modifier = Modifier .fillMaxSize() @@ -280,21 +529,10 @@ private fun CallRoomContent( ) } if (showInCallControls) { - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(168.dp + navBottomDp) - .zIndex(0.5f) - .hazeEffect(state = hazeState, style = HazeMaterials.thin()) { - progressive = HazeProgressive.verticalGradient( - startIntensity = 0f, - endIntensity = 1f, - ) - }, - ) CallInlineControlBar( room = room, + micRequestedOn = micRequestedOn, + onMicRequestedChange = onMicRequestedChange, hazeState = hazeState, modifier = Modifier .align(Alignment.BottomCenter) @@ -336,7 +574,7 @@ private fun SoloCallParticipantVideos( session: LiveKitConnectSession, showInCallControls: Boolean, ) { - var localCamOn by remember { mutableStateOf(true) } + var localCamOn by remember { mutableStateOf(false) } var localLevel by remember { mutableFloatStateOf(0f) } LaunchedEffect(local) { while (isActive) { @@ -355,23 +593,28 @@ private fun SoloCallParticipantVideos( val selfPic = self?.id?.let { ProfileCache.get(it)?.profilePicture } ?: self?.profile_picture val selfName = self?.displayName?.takeIf { !it.isNullOrBlank() } ?: self?.username.orEmpty() val peerPic = ProfileCache.get(session.peerUserId)?.profilePicture - Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + ) { when { // Never full-screen your own screen share; show camera or placeholders instead. lcRef != null && localCamOn -> { - VideoTrackView( - trackReference = lcRef, - modifier = Modifier.fillMaxSize(), - room = room, - mirror = true, - scaleType = ScaleType.Fill, - ) + Box(Modifier.fillMaxSize()) { + VideoTrackView( + trackReference = lcRef, + modifier = Modifier.fillMaxSize(), + room = room, + mirror = true, + scaleType = ScaleType.Fill, + ) + } } lcRef != null && !localCamOn -> { RemoteVideoOffPlaceholder( displayName = selfName, profilePictureUrl = selfPic, - label = selfName, audioLevel = localLevel, modifier = Modifier.fillMaxSize(), ) @@ -380,27 +623,15 @@ private fun SoloCallParticipantVideos( RemoteVideoOffPlaceholder( displayName = session.peerDisplayName, profilePictureUrl = peerPic, - label = session.peerDisplayName, audioLevel = 0f, modifier = Modifier.fillMaxSize(), ) } } - CallVolumeAvatars( - remoteName = session.peerDisplayName, - remotePic = peerPic, - remoteLevel = 0f, - selfName = selfName, - selfPic = selfPic, - localLevel = localLevel, - modifier = Modifier - .align(Alignment.TopEnd) - .windowInsetsPadding(WindowInsets.statusBars) - .padding(12.dp), - ) } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun DuoCallParticipantVideos( room: Room, @@ -410,7 +641,7 @@ private fun DuoCallParticipantVideos( showInCallControls: Boolean, ) { var remoteCamOn by remember { mutableStateOf(true) } - var localCamOn by remember { mutableStateOf(true) } + var localCamOn by remember { mutableStateOf(false) } var remoteLevel by remember { mutableFloatStateOf(0f) } var localLevel by remember { mutableFloatStateOf(0f) } @@ -461,11 +692,12 @@ private fun DuoCallParticipantVideos( else -> null } - val effectiveMain = userMain ?: defaultMainSlot() + val effectiveMain: VideoSlot = (userMain ?: defaultMainSlot()) ?: VideoSlot.None LaunchedEffect(userMain, rsRef, rcRef, lsRef, lcRef, remoteCamOn, localCamOn) { val u = userMain ?: return@LaunchedEffect val ok = when (u) { + VideoSlot.None -> false VideoSlot.RemoteScreen -> rsRef != null VideoSlot.RemoteCam -> rcRef != null VideoSlot.LocalScreen -> false @@ -475,6 +707,7 @@ private fun DuoCallParticipantVideos( } fun refFor(slot: VideoSlot): TrackReference? = when (slot) { + VideoSlot.None -> null VideoSlot.RemoteScreen -> rsRef VideoSlot.RemoteCam -> rcRef VideoSlot.LocalScreen -> lsRef @@ -485,6 +718,7 @@ private fun DuoCallParticipantVideos( slot == VideoSlot.RemoteScreen || slot == VideoSlot.LocalScreen fun camEnabled(slot: VideoSlot): Boolean = when (slot) { + VideoSlot.None -> false VideoSlot.RemoteCam -> remoteCamOn VideoSlot.LocalCam -> localCamOn else -> true @@ -511,81 +745,101 @@ private fun DuoCallParticipantVideos( val selfPic = selfId?.let { ProfileCache.get(it)?.profilePicture } ?: self?.profile_picture val selfName = self?.displayName?.takeIf { !it.isNullOrBlank() } ?: self?.username.orEmpty() + val youLabel = stringResource(Res.string.message_sender_you) + val controlsReserve = if (showInCallControls) 168.dp else 0.dp + val screenShareMainBottomPad = controlsReserve + + fun streamOwner(slot: VideoSlot): CallOwnerUi = when (slot) { + VideoSlot.RemoteScreen, VideoSlot.RemoteCam -> + CallOwnerUi( + name = session.peerDisplayName, + avatarName = session.peerDisplayName, + pictureUrl = peerPic, + level = remoteLevel, + isSelf = false, + ) + VideoSlot.LocalCam, VideoSlot.LocalScreen -> + CallOwnerUi( + name = youLabel, + avatarName = selfName, + pictureUrl = selfPic, + level = localLevel, + isSelf = true, + ) + else -> + CallOwnerUi( + name = "", + avatarName = "", + pictureUrl = null, + level = 0f, + isSelf = false, + ) + } Box(modifier = Modifier.fillMaxSize()) { - val mainSlot = effectiveMain - val mainRef = mainSlot?.let { refFor(it) } - val swapMain: () -> Unit = { - if (previewSlots.isNotEmpty()) userMain = previewSlots.first() - } - Box(modifier = Modifier.fillMaxSize()) { - if (mainSlot != null && mainRef != null && (isScreen(mainSlot) || camEnabled(mainSlot))) { - VideoTrackView( - trackReference = mainRef, + SharedTransitionLayout(Modifier.fillMaxSize()) { + val shared = this@SharedTransitionLayout + Box(Modifier.fillMaxSize()) { + Box( modifier = Modifier .fillMaxSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = swapMain, - ), + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + ) { + val slot = effectiveMain + val mainRef = refFor(slot) + val screenInsetMod = if (isScreen(slot) && mainRef != null) { + Modifier + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(bottom = screenShareMainBottomPad) + } else { + Modifier + } + val baseModifier = Modifier.fillMaxSize().then(screenInsetMod) + when { + slot != VideoSlot.None && mainRef != null && (isScreen(slot) || camEnabled(slot)) -> { + CallSlotSharedVideo( + room = room, + sharedTransitionScope = shared, + slot = slot, + trackRef = mainRef, + mirror = slot == VideoSlot.LocalCam || slot == VideoSlot.LocalScreen, + scaleType = if (isScreen(slot)) ScaleType.FitInside else ScaleType.Fill, + modifier = baseModifier, + innerClipShape = null, + ) + } + slot != VideoSlot.None && mainRef != null && !isScreen(slot) && !camEnabled(slot) -> { + RemoteVideoOffPlaceholder( + displayName = session.peerDisplayName, + profilePictureUrl = if (slot == VideoSlot.RemoteCam) peerPic else selfPic, + audioLevel = if (slot == VideoSlot.RemoteCam) remoteLevel else localLevel, + modifier = baseModifier, + ) + } + else -> { + RemoteVideoOffPlaceholder( + displayName = session.peerDisplayName, + profilePictureUrl = peerPic, + audioLevel = remoteLevel, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + + CallPreviewCluster( room = room, - mirror = mainSlot == VideoSlot.LocalCam || mainSlot == VideoSlot.LocalScreen, - scaleType = ScaleType.Fill, - ) - } else if (mainSlot != null && mainRef != null && !isScreen(mainSlot) && !camEnabled(mainSlot)) { - RemoteVideoOffPlaceholder( - displayName = session.peerDisplayName, - profilePictureUrl = if (mainSlot == VideoSlot.RemoteCam) peerPic else selfPic, - label = if (mainSlot == VideoSlot.RemoteCam) session.peerDisplayName else selfName, - audioLevel = if (mainSlot == VideoSlot.RemoteCam) remoteLevel else localLevel, - modifier = Modifier - .fillMaxSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = swapMain, - ), - ) - } else { - RemoteVideoOffPlaceholder( - displayName = session.peerDisplayName, - profilePictureUrl = peerPic, - label = session.peerDisplayName, - audioLevel = remoteLevel, - modifier = Modifier - .fillMaxSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = swapMain, - ), + previewSlots = previewSlots, + refFor = ::refFor, + isScreen = ::isScreen, + camEnabled = ::camEnabled, + showInCallControls = showInCallControls, + onSelectMain = { s -> userMain = s }, + streamOwner = ::streamOwner, + sharedTransitionScope = shared, ) } } - - CallVolumeAvatars( - remoteName = session.peerDisplayName, - remotePic = peerPic, - remoteLevel = remoteLevel, - selfName = selfName, - selfPic = selfPic, - localLevel = localLevel, - modifier = Modifier - .align(Alignment.TopEnd) - .windowInsetsPadding(WindowInsets.statusBars) - .padding(12.dp), - ) - - CallPreviewCluster( - room = room, - previewSlots = previewSlots, - refFor = ::refFor, - isScreen = ::isScreen, - camEnabled = ::camEnabled, - showInCallControls = showInCallControls, - onSelectMain = { slot -> userMain = slot }, - ) } } @@ -593,7 +847,6 @@ private fun DuoCallParticipantVideos( private fun RemoteVideoOffPlaceholder( displayName: String, profilePictureUrl: String?, - label: String, audioLevel: Float, modifier: Modifier, ) { @@ -623,89 +876,21 @@ private fun RemoteVideoOffPlaceholder( ), contentAlignment = Alignment.Center, ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Avatar( - profilePictureUrl = profilePictureUrl, - displayName = displayName, - modifier = Modifier - .size(120.dp) - .clip(CircleShape) - .graphicsLayer { - scaleX = scale - scaleY = scale - }, - ) - Spacer(Modifier.size(16.dp)) - Text( - text = label, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 24.dp), - ) - } - } -} - -@Composable -private fun CallVolumeAvatars( - remoteName: String, - remotePic: String?, - remoteLevel: Float, - selfName: String, - selfPic: String?, - localLevel: Float, - modifier: Modifier, -) { - val cap = 0.55f - fun scaleFor(level: Float): Float { - val boost = (level.coerceIn(0f, cap) / cap).coerceIn(0f, 1f) - return 1f + boost * 0.12f - } - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(40.dp) - .graphicsLayer { - scaleX = scaleFor(remoteLevel) - scaleY = scaleFor(remoteLevel) - }, - contentAlignment = Alignment.Center, - ) { - Avatar( - profilePictureUrl = remotePic, - displayName = remoteName, - modifier = Modifier - .size(36.dp) - .clip(CircleShape), - ) - } - Box( - modifier = Modifier - .size(40.dp) - .graphicsLayer { - scaleX = scaleFor(localLevel) - scaleY = scaleFor(localLevel) - }, - contentAlignment = Alignment.Center, - ) { - Avatar( - profilePictureUrl = selfPic, - displayName = selfName, - modifier = Modifier - .size(36.dp) - .clip(CircleShape), - ) - } + Avatar( + profilePictureUrl = profilePictureUrl, + displayName = displayName, + modifier = Modifier + .size(120.dp) + .clip(CircleShape) + .graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun CallPreviewCluster( room: Room, @@ -715,6 +900,8 @@ private fun CallPreviewCluster( camEnabled: (VideoSlot) -> Boolean, showInCallControls: Boolean, onSelectMain: (VideoSlot) -> Unit, + streamOwner: (VideoSlot) -> CallOwnerUi, + sharedTransitionScope: SharedTransitionScope, ) { val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current @@ -727,27 +914,20 @@ private fun CallPreviewCluster( val gap = 8.dp val controlsReserve = if (showInCallControls) with(density) { 108.dp.toPx() } else with(density) { 16.dp.toPx() } - val handleH = 10.dp - val handlePx = with(density) { handleH.toPx() } val posX = remember { Animatable(0f) } val posY = remember { Animatable(0f) } var layoutReady by remember { mutableStateOf(false) } - var stripHorizontal by remember { mutableStateOf(false) } BoxWithConstraints(Modifier.fillMaxSize()) { - val marginPx = with(density) { 12.dp.toPx() } + val marginPx = with(density) { 16.dp.toPx() } val pipWpx = with(density) { 104.dp.toPx() } val pipHpx = with(density) { 138.dp.toPx() } val gapPx = with(density) { gap.toPx() } val n = previewSlots.size.coerceAtLeast(1) - val clusterHCol = n * pipHpx + (n - 1).coerceAtLeast(0) * gapPx + handlePx + with(density) { 4.dp.toPx() } - val clusterWRow = n * pipWpx + (n - 1).coerceAtLeast(0) * gapPx - + val clusterHCol = n * pipHpx + (n - 1).coerceAtLeast(0) * gapPx val maxXPxCol = with(density) { maxWidth.toPx() } - endPx - pipWpx val maxYPxCol = with(density) { maxHeight.toPx() } - bottomPx - controlsReserve - clusterHCol - val maxXPxRow = with(density) { maxWidth.toPx() } - endPx - clusterWRow - val topRowY = topPx + marginPx LaunchedEffect(maxWidth, maxHeight, bottomPx, controlsReserve, endPx, topPx) { if (!layoutReady) { @@ -762,11 +942,6 @@ private fun CallPreviewCluster( y = o.y.coerceIn(marginPx + topPx, maxYPxCol), ) - fun clampRow(o: Offset): Offset = Offset( - x = o.x.coerceIn(marginPx + startPx, maxXPxRow), - y = o.y.coerceIn(topRowY, topRowY), - ) - fun nearestCornerCol(o: Offset): Offset { val xs = listOf(marginPx + startPx, maxXPxCol) val ys = listOf(marginPx + topPx, maxYPxCol) @@ -786,32 +961,18 @@ private fun CallPreviewCluster( return best } - val springSnap = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ) - if (previewSlots.isEmpty()) return@BoxWithConstraints fun moveCluster(amount: Offset) { val raw = Offset(posX.value + amount.x, posY.value + amount.y) - val next = if (stripHorizontal) clampRow(raw) else clampCol(raw) - val wantStrip = !stripHorizontal && raw.y < topPx + with(density) { 88.dp.toPx() } - if (wantStrip && previewSlots.isNotEmpty()) { - stripHorizontal = true - scope.launch { - posX.snapTo(clampRow(Offset(posX.value, topRowY)).x) - posY.snapTo(topRowY) - } - return - } + val next = clampCol(raw) scope.launch { posX.snapTo(next.x) posY.snapTo(next.y) } } - val dragModifier = Modifier.pointerInput(layoutReady, stripHorizontal, maxXPxCol, maxYPxCol, maxXPxRow, topRowY, marginPx, startPx, topPx) { + val dragModifier = Modifier.pointerInput(layoutReady, maxXPxCol, maxYPxCol, marginPx, startPx, topPx) { if (!layoutReady) return@pointerInput detectDragGestures( onDrag = { change, amount -> @@ -820,33 +981,22 @@ private fun CallPreviewCluster( }, onDragEnd = { scope.launch { - val t = if (stripHorizontal) { - Offset( - posX.value.coerceIn(marginPx + startPx, maxXPxRow), - topRowY, - ) - } else { - nearestCornerCol(Offset(posX.value, posY.value)) - } + val t = nearestCornerCol(Offset(posX.value, posY.value)) coroutineScope { awaitAll( - async { posX.animateTo(t.x, springSnap) }, - async { posY.animateTo(t.y, springSnap) }, + async { posX.animateTo(t.x, pipReorgSpring) }, + async { posY.animateTo(t.y, pipReorgSpring) }, ) } } }, onDragCancel = { scope.launch { - val t = if (stripHorizontal) { - Offset(posX.value.coerceIn(marginPx + startPx, maxXPxRow), topRowY) - } else { - nearestCornerCol(Offset(posX.value, posY.value)) - } + val t = nearestCornerCol(Offset(posX.value, posY.value)) coroutineScope { awaitAll( - async { posX.animateTo(t.x, springSnap) }, - async { posY.animateTo(t.y, springSnap) }, + async { posX.animateTo(t.x, pipReorgSpring) }, + async { posY.animateTo(t.y, pipReorgSpring) }, ) } } @@ -858,66 +1008,52 @@ private fun CallPreviewCluster( modifier = Modifier .offset { IntOffset(posX.value.roundToInt(), posY.value.roundToInt()) }, ) { - if (!stripHorizontal) { - Column(verticalArrangement = Arrangement.spacedBy(gap)) { - for (slot in previewSlots) { - PreviewTile( - room = room, - ref = refFor(slot), - mirror = slot == VideoSlot.LocalCam || slot == VideoSlot.LocalScreen, - showVideo = refFor(slot) != null && (isScreen(slot) || camEnabled(slot)), - onTap = { onSelectMain(slot) }, - ) - } - Spacer(Modifier.size(4.dp)) - Box( - modifier = Modifier - .size(104.dp, handleH) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)) - .then(dragModifier), + Column(verticalArrangement = Arrangement.spacedBy(gap)) { + for (slot in previewSlots) { + val owner = streamOwner(slot) + PreviewTile( + room = room, + slot = slot, + sharedTransitionScope = sharedTransitionScope, + ref = refFor(slot), + mirror = slot == VideoSlot.LocalCam || slot == VideoSlot.LocalScreen, + showVideo = refFor(slot) != null && (isScreen(slot) || camEnabled(slot)), + displayName = owner.name, + avatarDisplayName = owner.avatarName, + profilePictureUrl = owner.pictureUrl, + audioLevel = owner.level, + onTap = { onSelectMain(slot) }, + dragModifier = dragModifier, ) } - } else { - Row( - horizontalArrangement = Arrangement.spacedBy(gap), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(10.dp, 138.dp) - .clip(RoundedCornerShape(6.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)) - .then(dragModifier), - ) - for (slot in previewSlots) { - PreviewTile( - room = room, - ref = refFor(slot), - mirror = slot == VideoSlot.LocalCam || slot == VideoSlot.LocalScreen, - showVideo = refFor(slot) != null && (isScreen(slot) || camEnabled(slot)), - onTap = { onSelectMain(slot) }, - ) - } - } } } } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun PreviewTile( room: Room, + slot: VideoSlot, + sharedTransitionScope: SharedTransitionScope, ref: TrackReference?, mirror: Boolean, showVideo: Boolean, + displayName: String, + avatarDisplayName: String, + profilePictureUrl: String?, + audioLevel: Float, onTap: () -> Unit, + dragModifier: Modifier = Modifier, ) { + val tileShape = RoundedCornerShape(16.dp) Box( modifier = Modifier .size(104.dp, 138.dp) - .clip(RoundedCornerShape(16.dp)) + .clip(tileShape) .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .then(dragModifier) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -925,21 +1061,34 @@ private fun PreviewTile( ), ) { if (ref != null && showVideo) { - VideoTrackView( - trackReference = ref, - modifier = Modifier.fillMaxSize(), + CallSlotSharedVideo( room = room, + sharedTransitionScope = sharedTransitionScope, + slot = slot, + trackRef = ref, mirror = mirror, scaleType = ScaleType.Fill, + modifier = Modifier.fillMaxSize(), + innerClipShape = tileShape, ) } else { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Filled.VideocamOff, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.55f)), + ) + } + if (displayName.isNotBlank()) { + StreamOwnerChip( + label = displayName, + avatarName = avatarDisplayName, + profilePictureUrl = profilePictureUrl, + audioLevel = audioLevel, + showName = true, + modifier = Modifier + .align(Alignment.TopStart) + .padding(6.dp), + ) } } } @@ -948,13 +1097,15 @@ private fun PreviewTile( @Composable private fun CallInlineControlBar( room: Room, + micRequestedOn: Boolean, + onMicRequestedChange: (Boolean) -> Unit, hazeState: dev.chrisbanes.haze.HazeState, modifier: Modifier, ) { val scope = rememberCoroutineScope() val localParticipant = room.localParticipant - var micOn by remember { mutableStateOf(true) } - var camOn by remember { mutableStateOf(true) } + var micOn by remember { mutableStateOf(micRequestedOn) } + var camOn by remember { mutableStateOf(false) } var shareOn by remember { mutableStateOf(false) } LaunchedEffect(localParticipant) { @@ -966,6 +1117,10 @@ private fun CallInlineControlBar( } } + LaunchedEffect(micRequestedOn) { + micOn = micRequestedOn + } + val context = LocalContext.current val shareNotifTitle = stringResource(Res.string.notif_screenshare_title) val shareNotifText = stringResource(Res.string.notif_screenshare_text) @@ -1029,23 +1184,25 @@ private fun CallInlineControlBar( .fillMaxWidth(0.86f) .clip(RoundedCornerShape(28.dp)) .hazeEffect(state = hazeState, style = HazeMaterials.thick()) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.34f)), - ) + .background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.48f)), + ) { Row( modifier = Modifier - .fillMaxWidth(0.86f) + .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 14.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { FilledTonalIconButton( onClick = { - scope.launch { - val next = !micOn - if (runCatching { localParticipant.setMicrophoneEnabled(next) }.isSuccess) { - micOn = next - } - } + val next = !micRequestedOn + Logger.d( + TAG, + "mic button: toggle $micRequestedOn → $next room.state=${room.state} " + + "lp.micEn=${localParticipant.isMicrophoneEnabled}", + ) + micOn = next + onMicRequestedChange(next) }, modifier = Modifier.size(52.dp), colors = IconButtonDefaults.filledTonalIconButtonColors( @@ -1155,5 +1312,6 @@ private fun CallInlineControlBar( ) } } + } } } \ No newline at end of file diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index c534604..7f44705 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -124,11 +124,32 @@ окт ноя дек - Подключение к серверу - Введите адрес сервера, к которому хотите подключиться - Адрес сервера - example.com + Настройка сервера + Подключение к альтернативному серверу FromChat. Это дает больше приватности и контроля над данными. + IP или имя сервера + 192.168.1.10 + Порт + 8301 + Порт звонков + 8302 Защищённое соединение + HTTPS. Отключайте только для HTTP без шифрования. + HTTPS + Защищённое соединение. Если сервер локальный, скорее всего вам нужно это отключить. + Укажите корректный IP или имя хоста. + Порт от 1 до 65535. + Сервер доступен, пинг: %1$d мс. + Сервер доступен (без звонков), пинг: %1$d мс. + Не удалось подключиться к серверу. + Порт звонков недоступен. + Сервер OK; порт звонков не ответил. + Сброшено на значения по умолчанию. + Проверить сервер + Сброс + Проверить + Сбросить + Сбросить настройки? + Будут восстановлены адрес сервера и порты по умолчанию. Сохранить и продолжить Сменить сервер Подключиться к альтернативному серверу FromChat и выйти из аккаунта. diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index cdb1d8f..abb65d5 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -152,10 +152,31 @@ Connect to a server - Enter the server you want to use - Server address - example.com - Use a secure connection + Connect to an alternative FromChat server. This can give you more privacy and control over data. + Server IP or hostname + 192.168.1.10 + Port + 8301 + Calls port + 8302 + Secure connection + Uses HTTPS. Turn off only for plain HTTP. + HTTPS + Secure connection. If your server is local, you probably should turn it off. + Enter a valid IP or hostname. + Enter a port from 1 to 65535. + Server OK. Calls port reachable. Ping: %1$d ms. + Server OK. Calls disabled (no port). Ping: %1$d ms. + Cannot reach the API. + Calls port not reachable. + Server OK; calls port did not respond. + Defaults applied. + Check server + Reset to defaults + Check + Reset + Reset to defaults? + This restores the default server and port settings. Save and continue diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index 3b0d8d0..55c2d26 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -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().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().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() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index 43f886e..e41e026 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -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, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt index e7e9be2..2ec2fc9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt @@ -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, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt index 52ffe25..d2e4a44 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt @@ -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) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt index f9098bf..22c26d0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt @@ -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 { + 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) } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt index 4f72096..d0b721d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt @@ -13,19 +13,20 @@ object Config { private val _serverConfig = MutableStateFlow(null) val serverConfig: StateFlow = _serverConfig.asStateFlow() - private val config: ServerConfigData get() { - if (_serverConfig.value == null) initialize() + private val config: ServerConfigData + get() { + if (_serverConfig.value == null) initialize() + return _serverConfig.value + ?: throw IllegalStateException("Server configuration not initialized") + } - return (_serverConfig.value ?: IllegalStateException("Server configuration not initialized")) as ServerConfigData - } - /** * Initialize configuration by loading from storage */ fun initialize() { _serverConfig.value = Settings.serverConfig } - + /** * Update server configuration */ @@ -33,16 +34,35 @@ object Config { Settings.serverConfig = config _serverConfig.value = config } - + + /** Voice/video calls (LiveKit) are enabled only when a calls (WebRTC) port is configured. */ + val callsEnabled: Boolean + get() = config.callsPort != null + + /** + * LiveKit signaling WebSocket URL: same host and API port as HTTPS reverse proxy (`/api/livekit/rtc`). + * WebRTC media uses the configured calls port on the server (e.g. HAProxy in front of LiveKit). + */ + fun liveKitSignalingWsUrl(): String { + val scheme = if (config.httpsEnabled) "wss" else "ws" + return "$scheme://${config.serverIp}:${config.apiPort}/api/livekit/rtc" + } + /** * Get API base URL based on current server configuration */ - val apiBaseUrl - get() = "${if (config.httpsEnabled) "https" else "http"}://${config.serverUrl}/api" - + val apiBaseUrl: String + get() { + val scheme = if (config.httpsEnabled) "https" else "http" + return "$scheme://${config.serverIp}:${config.apiPort}/api" + } + /** - * Get WebSocket URL based on current server configuration + * Get WebSocket URL for app chat signaling (proxied on the same port as HTTPS / API). */ - val webSocketUrl - get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws" + val webSocketUrl: String + get() { + val scheme = if (config.httpsEnabled) "wss" else "ws" + return "$scheme://${config.serverIp}:${config.apiPort}/api/chat/ws" + } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 28f34ca..1fcbbbf 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -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(400) + val rootNavMotion = spring(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, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/FromChatSnackbarHost.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/FromChatSnackbarHost.kt new file mode 100644 index 0000000..313fb73 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/FromChatSnackbarHost.kt @@ -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, + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt index fe1220a..7268531 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt @@ -194,6 +194,7 @@ fun LoginScreen( throw e } ApiClient.persistSessionToStorage(response) + runCatching { ApiClient.refreshServerInstanceFingerprint() } response } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt index f2d95a3..59c8565 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt @@ -199,6 +199,7 @@ fun RegisterScreen( throw e } ApiClient.persistSessionToStorage(response) + runCatching { ApiClient.refreshServerInstanceFingerprint() } response } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index e029a56..bedde72 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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}") } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt index 471527f..899bf97 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt index 4b6cf88..a30013d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt @@ -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( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt index 9531bf3..636fd61 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt @@ -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 ) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt index d682ed5..a23bcd3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt @@ -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)) }, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigHostValidation.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigHostValidation.kt new file mode 100644 index 0000000..66efd10 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigHostValidation.kt @@ -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 +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt index 1d9f51e..f514e72 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt @@ -1,171 +1,1008 @@ package ru.fromchat.ui.setup +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Http +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.zIndex +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.pr0gramm3r101.components.Category +import com.pr0gramm3r101.components.SwitchListItem import com.pr0gramm3r101.utils.navigateAndWipeBackStack +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import dev.chrisbanes.haze.rememberHazeState +import kotlin.time.TimeSource +import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.* import ru.fromchat.api.ApiClient import ru.fromchat.api.WebSocketManager +import ru.fromchat.api_port_hint +import ru.fromchat.api_port_label +import ru.fromchat.back +import ru.fromchat.calls_port_hint +import ru.fromchat.calls_port_label +import ru.fromchat.cancel +import ru.fromchat.confirm import ru.fromchat.core.ServerConfigData +import ru.fromchat.core.Settings import ru.fromchat.core.config.Config +import ru.fromchat.save_continue +import ru.fromchat.server_config_action_check +import ru.fromchat.server_config_action_reset +import ru.fromchat.server_config_action_reset_confirm_body +import ru.fromchat.server_config_action_reset_confirm_title +import ru.fromchat.server_config_host_error +import ru.fromchat.server_config_https_headline +import ru.fromchat.server_config_https_local_hint +import ru.fromchat.server_config_port_error +import ru.fromchat.server_config_snackbar_api_fail +import ru.fromchat.server_config_snackbar_defaults +import ru.fromchat.server_config_snackbar_ok_api_calls_bad +import ru.fromchat.server_config_snackbar_ok_calls +import ru.fromchat.server_config_snackbar_ok_calls_skip +import ru.fromchat.server_config_subtitle +import ru.fromchat.server_config_title +import ru.fromchat.server_ip_hint +import ru.fromchat.server_ip_label +import ru.fromchat.ui.FromChatSnackbarHost import ru.fromchat.ui.LocalNavController +import ru.fromchat.ui.main.settings.SettingsExpressiveIconFrame +import ru.fromchat.ui.main.settings.SettingsPasswordOutlineFieldShape +import ru.fromchat.ui.main.settings.SettingsSecurityCtaShape +import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding +import kotlin.math.roundToInt -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ServerConfigScreen() { - val navController = LocalNavController.current - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) - - // Load existing config if available - var serverUrl by remember { mutableStateOf("") } - var httpsEnabled by remember { mutableStateOf(true) } - - LaunchedEffect(Unit) { - Config.serverConfig.value?.let { - serverUrl = it.serverUrl - httpsEnabled = it.httpsEnabled +/** Tighter than chat’s +12.dp tail under the 64.dp toolbar row inside the frosted top bar. */ +private val ServerConfigTopBarBlurBottomInset = 4.dp +private val ServerConfigFocusedFieldViewportMargin = 12.dp + +private const val ServerConfigHostItemIndex = 2 +private const val ServerConfigPortsItemIndex = 4 + +/** Short wait for IME + Scaffold bottom inset to apply before measuring clip vs CTA. */ +private const val ServerConfigKeyboardScrollSettleMs = 40L + +private val ServerConfigLazyListItemSpacing = 4.dp + +/** + * Fixed [Dp] gaps between all children (resolved in [Density.arrange] like [Arrangement.spacedBy]), + * plus extra height inserted **only** before the last child so it sits at the bottom when content + * is shorter than the viewport. Uses a stable [remember] so [4.dp.roundToPx] changes from + * recomposition do not swap the arrangement instance and nudge spacing by a pixel. + */ +@Stable +private class ServerConfigSpacedByLastAnchoredBottom( + private val space: Dp, +) : Arrangement.Vertical { + override val spacing: Dp get() = space + + override fun Density.arrange( + totalSize: Int, + sizes: IntArray, + outPositions: IntArray, + ) { + val spacePx = space.roundToPx() + val n = sizes.size + if (n == 0) return + if (n == 1) { + outPositions[0] = (totalSize - sizes[0]).coerceAtLeast(0) + return } + var sumHeights = 0 + for (i in 0 until n) { + sumHeights += sizes[i] + } + val gaps = spacePx * (n - 1) + val slack = (totalSize - sumHeights - gaps).coerceAtLeast(0) + var y = 0 + for (i in 0 until n - 1) { + outPositions[i] = y + y += sizes[i] + spacePx + } + outPositions[n - 1] = y + slack + } +} + +private fun apiBaseUrlFor(config: ServerConfigData): String { + val scheme = if (config.httpsEnabled) "https" else "http" + return "$scheme://${config.serverIp}:${config.apiPort}/api" +} + +private fun resolvedApiPort(apiPortText: String): Int { + val t = apiPortText.trim() + if (t.isEmpty()) return 443 + val n = t.toIntOrNull() ?: return 443 + return n.takeIf { it in 1..65535 } ?: 443 +} + +private fun resolvedCallsPort(callsPortText: String): Int? = + if (callsPortText.isBlank()) null + else callsPortText.trim().toIntOrNull()?.takeIf { it in 1..65535 } + +private suspend fun LazyListState.scrollFocusedItemIntoView( + itemIndex: Int, + viewportMarginPx: Float, +) { + if (layoutInfo.visibleItemsInfo.none { it.index == itemIndex }) { + animateScrollToItem(itemIndex) } - var isLoading by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - TopAppBar( - title = {}, - navigationIcon = { - if (navController.currentBackStackEntry != null) { - IconButton(onClick = navController::navigateUp) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.back) - ) - } - } - }, - scrollBehavior = scrollBehavior + val item = layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemIndex } ?: return + val viewportStart = layoutInfo.viewportStartOffset + viewportMarginPx + // viewportEndOffset includes the afterContentPadding area. In this screen that padding comes + // from Scaffold's bottom bar/IME inset, so it is scrollable space, not unobscured viewport. + val viewportEnd = (layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding - viewportMarginPx) + .coerceAtLeast(viewportStart) + val itemStart = item.offset.toFloat() + val itemEnd = itemStart + item.size + val scrollDelta = when { + itemStart < viewportStart -> itemStart - viewportStart + itemEnd > viewportEnd -> itemEnd - viewportEnd + else -> 0f + } + + if (scrollDelta != 0f) { + animateScrollBy(scrollDelta) + } +} + +@Composable +private fun ServerConfigActionButtons( + onReset: () -> Unit, + onVerify: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextButton( + onClick = onVerify, + modifier = Modifier.weight(1f), + shape = SettingsSecurityCtaShape, + colors = ButtonDefaults.textButtonColors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.primary, + disabledContainerColor = Color.Transparent, + disabledContentColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f), + ), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), + ) { + ServerConfigActionButtonContent( + imageVector = Icons.Filled.FlashOn, + text = stringResource(Res.string.server_config_action_check), ) } - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .windowInsetsPadding(WindowInsets.ime) - .padding(horizontal = 24.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + TextButton( + onClick = onReset, + modifier = Modifier.weight(1f), + shape = SettingsSecurityCtaShape, + colors = ButtonDefaults.textButtonColors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.primary, + disabledContainerColor = Color.Transparent, + disabledContentColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f), + ), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), ) { - Text( - text = stringResource(Res.string.server_config_title), - style = MaterialTheme.typography.headlineMedium + ServerConfigActionButtonContent( + imageVector = Icons.Filled.RestartAlt, + text = stringResource(Res.string.server_config_action_reset), ) + } + } +} - Spacer(modifier = Modifier.height(8.dp)) +@Composable +private fun ServerConfigActionButtonContent( + imageVector: ImageVector, + text: String, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier + .size(16.dp) + .clip(SettingsSecurityCtaShape), + tint = MaterialTheme.colorScheme.primary, + ) + Text(text) + } +} - Text( - text = stringResource(Res.string.server_config_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) +@Composable +private fun ServerConfigLeadingIcon( + imageVector: ImageVector, + tint: Color, +) { + Icon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = tint, + ) +} - Spacer(modifier = Modifier.height(32.dp)) +@Composable +private fun ServerConfigHttpsLeadingIcon(tint: Color) { + Box( + Modifier.size(40.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Shield, + contentDescription = null, + modifier = Modifier.size(26.dp), + tint = tint, + ) + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + modifier = Modifier + .size(11.dp) + .offset(y = 4.dp), + tint = tint, + ) + } +} - OutlinedTextField( - value = serverUrl, - onValueChange = { serverUrl = it }, - label = { Text(stringResource(Res.string.server_url_label)) }, - placeholder = { Text(stringResource(Res.string.server_url_hint)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = httpsEnabled, - onCheckedChange = { httpsEnabled = it } - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(Res.string.https_enabled)) - } - - Spacer(modifier = Modifier.height(32.dp)) - - Button( - onClick = { - isLoading = true - scope.launch { - Config.updateServerConfig(ServerConfigData(serverUrl, httpsEnabled)) - - // Ensure we're logged out when server config changes - runCatching { - ApiClient.logout() - } - - // Clear API client state - ApiClient.clearMemorySession() - - // Restart websocket connection flow so it uses the new server config - WebSocketManager.disconnect() - WebSocketManager.connect(forceRestart = true) - - // Navigate to login and wipe entire back stack - navController.navigateAndWipeBackStack("login") - } +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +private fun ServerConfigStickyTopBar( + hazeState: HazeState, + topBarBlurHeight: Dp, + onNavigateUp: () -> Unit, +) { + val scheme = MaterialTheme.colorScheme + val iconTint = scheme.onSurfaceVariant + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(topBarBlurHeight) + .align(Alignment.TopCenter) + .hazeEffect(state = hazeState, style = HazeMaterials.thin()) { + progressive = HazeProgressive.verticalGradient( + startIntensity = 1f, + endIntensity = 0f, + ) }, - enabled = !isLoading && serverUrl.isNotBlank(), - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(Res.string.save_continue)) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .height(IntrinsicSize.Min) + .zIndex(1f) + .align(Alignment.TopCenter) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(start = 8.dp, end = 8.dp, top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onNavigateUp) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back), + tint = iconTint, + ) } } } } + +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalAnimationApi::class, + ExperimentalHazeMaterialsApi::class, +) +@Composable +fun ServerConfigScreen() { + val navController = LocalNavController.current + val scope = rememberCoroutineScope() + val scheme = MaterialTheme.colorScheme + val snackbarHostState = remember { SnackbarHostState() } + + var serverIp by remember { mutableStateOf("") } + var apiPortText by remember { mutableStateOf("") } + var callsPortText by remember { mutableStateOf("") } + var httpsEnabled by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + val c = Settings.serverConfig + serverIp = c.serverIp + apiPortText = c.apiPort.toString() + callsPortText = c.callsPort?.toString().orEmpty() + httpsEnabled = c.httpsEnabled + } + + var busy by remember { mutableStateOf(false) } + var showResetDialog by remember { mutableStateOf(false) } + val actionHazeState = rememberHazeState() + val serverConfigListState = rememberLazyListState() + var focusedItemIndex by remember { mutableStateOf(null) } + + val strSnackbarDefaults = stringResource(Res.string.server_config_snackbar_defaults) + val strHostError = stringResource(Res.string.server_config_host_error) + val strPortError = stringResource(Res.string.server_config_port_error) + val strSnackbarApiFail = stringResource(Res.string.server_config_snackbar_api_fail) + val strSnackbarOkApiCallsBad = stringResource(Res.string.server_config_snackbar_ok_api_calls_bad) + val strResetConfirmTitle = stringResource(Res.string.server_config_action_reset_confirm_title) + val strResetConfirmBody = stringResource(Res.string.server_config_action_reset_confirm_body) + + val portGap = 12.dp + + val hostOk = serverIp.isNotBlank() && isValidIpOrHostname(serverIp) + val hostError = serverIp.isNotEmpty() && !isValidIpOrHostname(serverIp) + val apiPortError = apiPortText.isNotEmpty() && !isValidPortNumber(apiPortText) + val callsPortError = callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText) + + val apiPortEffective = resolvedApiPort(apiPortText) + val callsPortParsed = resolvedCallsPort(callsPortText) + val canApply = + hostOk && + !apiPortError && + !callsPortError && + !busy + + val resetToDefaults = { + serverIp = "fromchat.ru" + apiPortText = "443" + callsPortText = "8302" + httpsEnabled = true + scope.launch { + snackbarHostState.showSnackbar(strSnackbarDefaults) + } + } + + val verifyServer: () -> Unit = { + scope.launch { + val host = serverIp.trim() + if (host.isEmpty() || !isValidIpOrHostname(host)) { + snackbarHostState.showSnackbar(strHostError) + return@launch + } + if (apiPortText.isNotEmpty() && !isValidPortNumber(apiPortText)) { + snackbarHostState.showSnackbar(strPortError) + return@launch + } + if (callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText)) { + snackbarHostState.showSnackbar(strPortError) + return@launch + } + val apiPort = resolvedApiPort(apiPortText) + val calls = resolvedCallsPort(callsPortText) + val tentative = ServerConfigData( + serverIp = host, + apiPort = apiPort, + callsPort = calls, + httpsEnabled = httpsEnabled, + ) + val apiBase = apiBaseUrlFor(tentative) + val pingMark = TimeSource.Monotonic.markNow() + val id = runCatching { ApiClient.fetchServerInstanceId(apiBase) } + .getOrNull() + ?.trim() + .orEmpty() + val pingMs = pingMark.elapsedNow().inWholeMilliseconds + .toInt() + .coerceAtLeast(0) + if (id.isEmpty()) { + snackbarHostState.showSnackbar(strSnackbarApiFail) + return@launch + } + val urlScheme = if (httpsEnabled) "https" else "http" + val callsOk = if (calls != null) { + val root = "$urlScheme://${hostForAuthority(host)}:${calls}/" + ApiClient.probeHttpGet(root) + } else { + null + } + val msg = when { + calls == null -> + getString(Res.string.server_config_snackbar_ok_calls_skip, pingMs) + callsOk == true -> + getString(Res.string.server_config_snackbar_ok_calls, pingMs) + else -> strSnackbarOkApiCallsBad + } + snackbarHostState.showSnackbar(msg) + } + } + + val fieldColors = OutlinedTextFieldDefaults.colors( + focusedTextColor = scheme.onSurface, + unfocusedTextColor = scheme.onSurface, + disabledTextColor = scheme.onSurface.copy(alpha = 0.38f), + focusedLabelColor = scheme.primary, + unfocusedLabelColor = scheme.onSurfaceVariant, + disabledLabelColor = scheme.onSurfaceVariant.copy(alpha = 0.38f), + cursorColor = scheme.primary, + focusedBorderColor = scheme.primary, + unfocusedBorderColor = scheme.outline, + disabledBorderColor = scheme.onSurface.copy(alpha = 0.12f), + errorBorderColor = scheme.error, + errorLabelColor = scheme.error, + errorCursorColor = scheme.error, + errorSupportingTextColor = scheme.error, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + ) + + val iconTint = scheme.onSurfaceVariant + val density = LocalDensity.current + val focusedFieldViewportMarginPx = with(density) { ServerConfigFocusedFieldViewportMargin.toPx() } + val listVerticalArrangement = remember { + ServerConfigSpacedByLastAnchoredBottom(space = ServerConfigLazyListItemSpacing) + } + val disabledBringIntoViewSpec = remember { + object : BringIntoViewSpec { + override fun calculateScrollDistance( + offset: Float, + size: Float, + containerSize: Float, + ): Float = 0f + } + } + + Box(Modifier.fillMaxSize()) { + Scaffold( + modifier = Modifier.fillMaxSize(), + // Match ChatScreen: do not apply safeDrawing top to content — status bar handled on the floating top bar only. + contentWindowInsets = WindowInsets.navigationBars, + containerColor = Color.Transparent, + contentColor = scheme.onSurface, + snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, + bottomBar = { + Column( + modifier = Modifier + .windowInsetsPadding(WindowInsets.ime) + .fillMaxWidth() + .hazeEffect(state = actionHazeState, style = HazeMaterials.thin()) { + progressive = HazeProgressive.verticalGradient( + startIntensity = 0f, + endIntensity = 1f, + ) + }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = SettingsStepHorizontalPadding) + .padding(top = 0.dp, bottom = 16.dp), + ) { + val showCtaAsPrimary = canApply || busy + val ctaTargetContainer = + if (showCtaAsPrimary) scheme.primary else scheme.surfaceContainerHigh + val ctaTargetContent = + if (showCtaAsPrimary) scheme.onPrimary else scheme.onSurface.copy(alpha = 0.38f) + val ctaContainer by animateColorAsState( + ctaTargetContainer, + animationSpec = tween(durationMillis = 220), + label = "serverConfigCtaContainer", + ) + val ctaContent by animateColorAsState( + ctaTargetContent, + animationSpec = tween(durationMillis = 220), + label = "serverConfigCtaContent", + ) + Button( + onClick = { + if (!canApply) return@Button + scope.launch { + busy = true + try { + val tentative = ServerConfigData( + serverIp = serverIp.trim(), + apiPort = apiPortEffective, + callsPort = callsPortParsed, + httpsEnabled = httpsEnabled, + ) + val tentativeApi = apiBaseUrlFor(tentative) + val newId = runCatching { + ApiClient.fetchServerInstanceId(tentativeApi) + }.getOrNull()?.trim().orEmpty() + if (newId.isEmpty()) { + withContext(Dispatchers.Main) { + reloginClearingSession(navController) + } + return@launch + } + + val bearer = ApiClient.token?.trim().orEmpty() + if (bearer.isEmpty()) { + Config.updateServerConfig(tentative) + Settings.lastKnownServerInstanceId = newId + WebSocketManager.disconnect() + withContext(Dispatchers.Main) { + navController.navigateAndWipeBackStack("login") + } + return@launch + } + + val persisted = Settings.lastKnownServerInstanceId.trim() + if (persisted.isNotEmpty() && !newId.equals(persisted, ignoreCase = true)) { + withContext(Dispatchers.Main) { + reloginClearingSession(navController) + } + return@launch + } + + val authOk = ApiClient.checkAuthAt(tentativeApi, bearer) + if (!authOk) { + withContext(Dispatchers.Main) { + reloginClearingSession(navController) + } + return@launch + } + + Config.updateServerConfig(tentative) + Settings.lastKnownServerInstanceId = newId + WebSocketManager.disconnect() + WebSocketManager.connect(forceRestart = true) + withContext(Dispatchers.Main) { + if (!navController.popBackStack()) { + navController.navigate("chat") { + popUpTo("login") { inclusive = true } + } + } + } + } finally { + busy = false + } + } + }, + enabled = canApply, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp), + shape = SettingsSecurityCtaShape, + colors = ButtonDefaults.buttonColors( + containerColor = ctaContainer, + contentColor = ctaContent, + disabledContainerColor = ctaContainer, + disabledContentColor = ctaContent, + ), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + focusedElevation = 0.dp, + hoveredElevation = 0.dp, + disabledElevation = 0.dp, + ), + ) { + Box( + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 24.dp), + contentAlignment = Alignment.Center, + ) { + AnimatedContent( + targetState = busy, + transitionSpec = { + (fadeIn(tween(200)) + slideInVertically { it / 4 }) togetherWith + (fadeOut(tween(200)) + slideOutVertically { -it / 4 }) + }, + label = "server_config_apply_cta", + ) { loading -> + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = ctaContent, + ) + } else { + Text(stringResource(Res.string.save_continue)) + } + } + } + } + } + } + }, + ) { innerPadding -> + val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() } + val floatingHeaderClearance = statusBarTopDp + 64.dp + ServerConfigTopBarBlurBottomInset + val bottomInsetPadding = innerPadding.calculateBottomPadding() + + LaunchedEffect(focusedItemIndex, bottomInsetPadding) { + val itemIndex = focusedItemIndex ?: return@LaunchedEffect + // Let the IME-adjusted Scaffold padding and LazyColumn layout settle, then do our + // own minimal scroll. This replaces Compose's generic focus relocation behavior. + delay(ServerConfigKeyboardScrollSettleMs) + serverConfigListState.scrollFocusedItemIntoView( + itemIndex = itemIndex, + viewportMarginPx = focusedFieldViewportMarginPx, + ) + } + + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize()) { + CompositionLocalProvider(LocalBringIntoViewSpec provides disabledBringIntoViewSpec) { + LazyColumn( + state = serverConfigListState, + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(innerPadding) + .background(MaterialTheme.colorScheme.background) + .hazeSource(actionHazeState), + contentPadding = PaddingValues(bottom = bottomInsetPadding), + verticalArrangement = listVerticalArrangement, + ) { + item { Spacer(Modifier.height(floatingHeaderClearance)) } + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SettingsStepHorizontalPadding) + .clip(SettingsPasswordOutlineFieldShape) + .background(Color.Transparent), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SettingsExpressiveIconFrame( + icon = Icons.Filled.Storage, + containerSize = 96.dp, + iconSize = 34.dp, + containerColor = scheme.primaryContainer, + contentColor = scheme.onPrimaryContainer, + materialPolygon = MaterialShapes.Cookie6Sided, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + text = stringResource(Res.string.server_config_title), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(Res.string.server_config_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = scheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(12.dp)) + } + } + } + } + item { + OutlinedTextField( + value = serverIp, + onValueChange = { serverIp = filterHostInput(it) }, + label = { Text(stringResource(Res.string.server_ip_label)) }, + placeholder = { Text(stringResource(Res.string.server_ip_hint)) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { + if (it.isFocused) { + focusedItemIndex = ServerConfigHostItemIndex + } + } + .padding(horizontal = SettingsStepHorizontalPadding), + singleLine = true, + isError = hostError, + supportingText = if (hostError) { + { Text(stringResource(Res.string.server_config_host_error)) } + } else null, + colors = fieldColors, + shape = SettingsPasswordOutlineFieldShape, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + leadingIcon = { + ServerConfigLeadingIcon(Icons.Filled.Dns, iconTint) + }, + ) + } + item { Spacer(Modifier.height(8.dp)) } + item { + Layout( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SettingsStepHorizontalPadding), + content = { + OutlinedTextField( + value = apiPortText, + onValueChange = { v -> apiPortText = v.filter { it.isDigit() }.take(6) }, + label = { + Text( + text = stringResource(Res.string.api_port_label), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + placeholder = { Text(stringResource(Res.string.api_port_hint)) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { + if (it.isFocused) { + focusedItemIndex = ServerConfigPortsItemIndex + } + }, + singleLine = true, + isError = apiPortError, + supportingText = if (apiPortError) { + { Text(stringResource(Res.string.server_config_port_error)) } + } else null, + colors = fieldColors, + shape = SettingsPasswordOutlineFieldShape, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Next, + ), + leadingIcon = { + ServerConfigLeadingIcon(Icons.Filled.Http, iconTint) + }, + ) + OutlinedTextField( + value = callsPortText, + onValueChange = { v -> callsPortText = v.filter { it.isDigit() }.take(6) }, + label = { + Text( + text = stringResource(Res.string.calls_port_label), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + placeholder = { Text(stringResource(Res.string.calls_port_hint)) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { + if (it.isFocused) { + focusedItemIndex = ServerConfigPortsItemIndex + } + }, + singleLine = true, + isError = callsPortError, + supportingText = if (callsPortError) { + { Text(stringResource(Res.string.server_config_port_error)) } + } else null, + colors = fieldColors, + shape = SettingsPasswordOutlineFieldShape, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Done, + ), + leadingIcon = { + ServerConfigLeadingIcon(Icons.Filled.Call, iconTint) + }, + ) + }, + ) { measurables, constraints -> + val maxW = constraints.maxWidth + val gapPx = portGap.roundToPx() + val avail = (maxW - gapPx).coerceAtLeast(0) + val minCallsPx = 168.dp.roundToPx() + val callsPx = + (avail * 0.60f) + .roundToInt() + .coerceAtLeast(minCallsPx) + .coerceAtMost(avail) + val apiPx = (avail - callsPx).coerceAtLeast(0) + if (measurables.size != 2) { + return@Layout layout(0, 0) {} + } + val w0 = measurables[0].measure(Constraints.fixedWidth(apiPx)) + val w1 = measurables[1].measure(Constraints.fixedWidth(callsPx)) + val h = maxOf(w0.height, w1.height) + .coerceIn(constraints.minHeight, constraints.maxHeight) + layout(maxW, h) { + w0.place(0, 0) + w1.place(apiPx + gapPx, 0) + } + } + } + item { Spacer(Modifier.height(16.dp)) } + item { + Column { + Category( + modifier = Modifier.fillMaxWidth(), + margin = PaddingValues( + start = SettingsStepHorizontalPadding, + end = SettingsStepHorizontalPadding, + ), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + SwitchListItem( + modifier = Modifier.fillMaxWidth(), + headline = stringResource(Res.string.server_config_https_headline), + supportingText = stringResource(Res.string.server_config_https_local_hint), + leadingContent = { + ServerConfigHttpsLeadingIcon(iconTint) + }, + checked = httpsEnabled, + onCheckedChange = { httpsEnabled = it }, + divider = false, + ) + } + } + } + item { + ServerConfigActionButtons( + onReset = { showResetDialog = true }, + onVerify = verifyServer, + modifier = Modifier + .fillMaxWidth() + .background(Color.Transparent) + .padding(horizontal = SettingsStepHorizontalPadding) + .padding(top = 8.dp, bottom = 6.dp), + ) + } + } + } + + Box(Modifier.align(Alignment.TopCenter)) { + ServerConfigStickyTopBar( + hazeState = actionHazeState, + topBarBlurHeight = floatingHeaderClearance, + onNavigateUp = { navController.navigateUp() }, + ) + } + } + if (showResetDialog) { + AlertDialog( + onDismissRequest = { + showResetDialog = false + }, + title = { + Text(strResetConfirmTitle) + }, + text = { + Text(strResetConfirmBody) + }, + confirmButton = { + TextButton( + onClick = { + showResetDialog = false + resetToDefaults() + }, + ) { + Text(stringResource(Res.string.confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showResetDialog = false }) { + Text(stringResource(Res.string.cancel)) + } + }, + ) + } + } + } + } +} + +private suspend fun reloginClearingSession(navController: NavController) { + WebSocketManager.disconnect() + runCatching { ApiClient.logout() } + ApiClient.clearMemorySession() + navController.navigateAndWipeBackStack("login") +}