diff --git a/.idea/artifacts/shared_jvm.xml b/.idea/artifacts/shared_jvm.xml
new file mode 100644
index 0000000..2435e77
--- /dev/null
+++ b/.idea/artifacts/shared_jvm.xml
@@ -0,0 +1,8 @@
+
+
+ $PROJECT_DIR$/utils/shared/build/libs
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index b86273d..b589d56 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index f7f8664..3d1446a 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -12,6 +12,7 @@
+
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 0000000..cc87bc5
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index b2c751a..3b0be22 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,6 +1,7 @@
+
-
+
diff --git a/README.en.md b/README.en.md
index 73f67b6..c25ef1d 100644
--- a/README.en.md
+++ b/README.en.md
@@ -2,14 +2,14 @@ Read in other languages: [Русский](./README.md)
# FromChat
-FromChat is a 100% free and open-source messenger. This repository is the cross-platform client (Android + iOS; iOS is not ready yet).
+FromChat is a 100% free and open-source messenger. This repository is the cross-platform client (Android, Desktop via Compose Multiplatform, and iOS).
[📥 Download](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram Channel](https://t.me/fromchat_ch) • [🖥️ Server](https://github.com/fromchat-messenger/backend)
## ✨ Features
- **Voice and video calls** — LiveKit
-- **Screen sharing** during calls
+- **Screen sharing** during calls (Android)
- **Public chat** — server-wide community
- **Direct messages** — legal encryption scheme; server-side E2EE planned
- **Device management** — active sessions
@@ -18,15 +18,16 @@ FromChat is a 100% free and open-source messenger. This repository is the cross-
## 📊 Client Comparison
-⚠️ **iOS is temporarily not supported** (Apple constraints and development cost). It will ship later.
+| Feature | Android | Desktop | Web | iOS |
+| --- | --- | --- | --- | --- |
+| **Messaging and profiles** | ✅ | ✅ | ✅ | ✅ |
+| **Voice/video calls** | ✅ | ✅ | ✅ | ✅ |
+| **Screen sharing** | ✅ | ❌ | ✅ | ❌ |
+| **Push when backgrounded** | ✅ (FCM) | ✅ (persistent WS + tray) | ✅ | ❌ (WS while open) |
+| **Message reactions** | ❌ | ❌ | ✅ | ❌ |
+| **Rich attachment support** | ✅ | ✅ | ❌ | ✅ |
-| Feature | Android | Web | iOS |
-| --- | --- | --- | --- |
-| **Messaging and profiles** | ✅ | ✅ | ❌ |
-| **Voice/video calls** | ✅ | ✅ | ❌ |
-| **Screen sharing** | ✅ | ✅ | ❌ |
-| **Message reactions** | ❌ | ✅ | ❌ |
-| **Rich attachment support** | ✅ | ❌ | ❌ |
+Desktop replaces the former Electron client in the Web repo. Run with `./gradlew :app:desktop:run`.
---
diff --git a/README.md b/README.md
index 0b1eb25..9c3ec42 100644
--- a/README.md
+++ b/README.md
@@ -2,14 +2,14 @@
# FromChat
-FromChat — 100% бесплатный и открытый мессенджер. В этом репозитории — кроссплатформенный клиент (Android + iOS; iOS пока не готов).
+FromChat — 100% бесплатный и открытый мессенджер. В этом репозитории — кроссплатформенный клиент (Android, Desktop на Compose Multiplatform и iOS).
[📥 Скачать](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram-канал](https://t.me/fromchat_ch) • [🖥️ Сервер](https://github.com/fromchat-messenger/backend)
## ✨ Возможности
- **Голосовые и видеозвонки** — LiveKit
-- **Демонстрация экрана** во время звонков
+- **Демонстрация экрана** во время звонков (Android)
- **Общий чат** — сообщество пользователей сервера
- **Личные сообщения** — легальная схема шифрования; E2EE на сервере планируется
- **Управление устройствами** — активные сеансы
@@ -18,15 +18,16 @@ FromChat — 100% бесплатный и открытый мессенджер.
## 📊 Сравнение клиентов
-⚠️ **iOS временно не поддерживается** (ограничения Apple и объём работы). Клиент выйдет позже.
+| Возможность | Android | Desktop | Web | iOS |
+| --- | --- | --- | --- | --- |
+| **Обмен сообщениями и профили** | ✅ | ✅ | ✅ | ✅ |
+| **Голосовые/видеозвонки** | ✅ | ✅ | ✅ | ✅ |
+| **Демонстрация экрана** | ✅ | ❌ | ✅ | ❌ |
+| **Push в фоне** | ✅ (FCM) | ✅ (постоянный WS + tray) | ✅ | ❌ (WS пока открыто) |
+| **Реакции на сообщения** | ❌ | ❌ | ✅ | ❌ |
+| **Расширенная поддержка вложений** | ✅ | ✅ | ❌ | ✅ |
-| Возможность | Android | Web | iOS |
-| --- | --- | --- | --- |
-| **Обмен сообщениями и профили** | ✅ | ✅ | ❌ |
-| **Голосовые/видеозвонки** | ✅ | ✅ | ❌ |
-| **Демонстрация экрана** | ✅ | ✅ | ❌ |
-| **Реакции на сообщения** | ❌ | ✅ | ❌ |
-| **Расширенная поддержка вложений** | ✅ | ❌ | ❌ |
+Desktop заменяет бывший Electron-клиент в репозитории Web. Запуск: `./gradlew :app:desktop:run`.
---
diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts
new file mode 100644
index 0000000..62931d2
--- /dev/null
+++ b/app/desktop/build.gradle.kts
@@ -0,0 +1,106 @@
+plugins {
+ kotlin("jvm")
+ alias(libs.plugins.compose.multiplatform)
+ alias(libs.plugins.compose.compiler)
+}
+
+val javafxVersion = libs.versions.openjfx.get()
+val javafxClassifier = openjfxClassifier()
+val javafxModules = listOf("base", "graphics", "controls", "media", "web", "swing")
+
+dependencies {
+ implementation(compose.desktop.currentOs)
+ implementation(project(":app:shared"))
+ implementation(project(":utils:shared"))
+ implementation(libs.kotlinx.coroutines.swing)
+ // OpenJFX publishes empty jars without a classifier; declare every module
+ // with the host classifier and exclude transitive stubs.
+ javafxModules.forEach { module ->
+ implementation("org.openjfx:javafx-$module:$javafxVersion:$javafxClassifier") {
+ exclude(group = "org.openjfx")
+ }
+ }
+}
+
+compose.desktop {
+ application {
+ mainClass = "ru.fromchat.desktop.MainKt"
+
+ nativeDistributions {
+ packageName = "FromChat"
+ packageVersion = "1.0.0"
+ description = "FromChat desktop"
+ copyright = "© FromChat"
+
+ modules(
+ "javafx.base",
+ "javafx.graphics",
+ "javafx.controls",
+ "javafx.media",
+ "javafx.web",
+ "javafx.swing",
+ )
+
+ macOS {
+ bundleID = "ru.fromchat.desktop"
+ infoPlist {
+ extraKeysRawXml = """
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ FromChat
+ CFBundleURLSchemes
+
+ fromchat
+
+
+
+ """.trimIndent()
+ }
+ }
+ }
+ }
+}
+
+kotlin {
+ jvmToolchain(17)
+}
+
+/**
+ * Compose Desktop puts the whole runtime classpath on the unnamed module path.
+ * JavaFX 11+ must be loaded as named modules via --module-path / --add-modules,
+ * and those jars must not also sit on the regular classpath.
+ */
+tasks.matching { it.name == "run" }.configureEach {
+ if (this !is JavaExec) return@configureEach
+ doFirst {
+ val javafxJars = classpath.files.filter { file ->
+ javafxModules.any { module -> file.name.startsWith("javafx-$module-") }
+ }
+ classpath = files(classpath.files.filterNot { it in javafxJars.toSet() })
+ jvmArgs(
+ listOf(
+ "--module-path",
+ javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
+ "--add-modules",
+ "javafx.controls,javafx.web,javafx.swing,javafx.media,javafx.graphics,javafx.base",
+ "--add-opens",
+ "javafx.graphics/com.sun.javafx.application=ALL-UNNAMED",
+ ),
+ )
+ }
+}
+
+private fun openjfxClassifier(): String {
+ val os = System.getProperty("os.name").lowercase()
+ val arch = System.getProperty("os.arch").lowercase()
+ val isArm = arch == "aarch64" || arch == "arm64"
+ return when {
+ os.contains("mac") && isArm -> "mac-aarch64"
+ os.contains("mac") -> "mac"
+ os.contains("win") -> "win"
+ isArm -> "linux-aarch64"
+ else -> "linux"
+ }
+}
diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt
new file mode 100644
index 0000000..b600172
--- /dev/null
+++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt
@@ -0,0 +1,249 @@
+package ru.fromchat.desktop
+
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.window.Notification
+import androidx.compose.ui.window.Tray
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberTrayState
+import androidx.compose.ui.window.rememberWindowState
+import com.pr0gramm3r101.utils.UtilsLibrary
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import ru.fromchat.AppForeground
+import ru.fromchat.api.ApiClient
+import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
+import ru.fromchat.ui.App
+import java.io.BufferedReader
+import java.io.InputStreamReader
+import java.io.OutputStreamWriter
+import java.net.InetAddress
+import java.net.ServerSocket
+import java.net.Socket
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.concurrent.thread
+
+private object DesktopApplicationBootstrap {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private var started = false
+
+ fun launchOnApplicationStart() {
+ if (started) return
+ started = true
+ AppForeground.setForeground(true)
+ scope.launch {
+ runCatching { ApiClient.loadPersistedData() }
+ runCatching { AttachmentTransferBootstrap.runColdStart() }
+ }
+ }
+}
+
+private object DesktopSingleInstance {
+ private const val PORT = 38_741
+ private val running = AtomicBoolean(false)
+
+ /**
+ * @return true if this process owns the instance and should show UI;
+ * false if another instance is already running (deep link forwarded).
+ */
+ fun acquireOrForward(args: Array): Boolean {
+ val deepLink = args.firstOrNull { DesktopDeepLinkBus.isFromChatUri(it) }
+ return try {
+ val server = ServerSocket(PORT, 8, InetAddress.getByName("127.0.0.1"))
+ running.set(true)
+ thread(name = "fromchat-deeplink-ipc", isDaemon = true) {
+ while (running.get()) {
+ runCatching {
+ server.accept().use { socket ->
+ val line = BufferedReader(
+ InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8),
+ ).readLine()?.trim().orEmpty()
+ if (DesktopDeepLinkBus.isFromChatUri(line)) {
+ DesktopDeepLinkBus.handleUri(line)
+ }
+ }
+ }
+ }
+ }
+ if (deepLink != null) {
+ DesktopDeepLinkBus.handleUri(deepLink)
+ }
+ true
+ } catch (_: Exception) {
+ if (deepLink != null) {
+ runCatching {
+ Socket("127.0.0.1", PORT).use { socket ->
+ OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8).use { out ->
+ out.write(deepLink)
+ out.write("\n")
+ out.flush()
+ }
+ }
+ }
+ }
+ false
+ }
+ }
+
+ fun release() {
+ running.set(false)
+ }
+}
+
+private object DesktopProtocolRegistration {
+ private const val SCHEME = "fromchat"
+
+ fun registerBestEffort() {
+ runCatching {
+ when {
+ isMac() -> Unit // Packaged macOS builds declare CFBundleURLTypes in Info.plist.
+ isWindows() -> registerWindows()
+ isLinux() -> registerLinux()
+ }
+ }
+ }
+
+ private fun isMac(): Boolean =
+ System.getProperty("os.name").orEmpty().lowercase().contains("mac")
+
+ private fun isWindows(): Boolean =
+ System.getProperty("os.name").orEmpty().lowercase().contains("win")
+
+ private fun isLinux(): Boolean =
+ System.getProperty("os.name").orEmpty().lowercase().contains("linux")
+
+ private fun javaLauncherCommand(): String {
+ val javaHome = System.getProperty("java.home")
+ val javaBin = if (isWindows()) "$javaHome\\bin\\javaw.exe" else "$javaHome/bin/java"
+ val classPath = System.getProperty("java.class.path")
+ val main = "ru.fromchat.desktop.MainKt"
+ return if (isWindows()) {
+ "\"$javaBin\" -cp \"$classPath\" $main"
+ } else {
+ "$javaBin -cp ${shellQuote(classPath)} $main"
+ }
+ }
+
+ private fun shellQuote(value: String): String =
+ "'" + value.replace("'", "'\\''") + "'"
+
+ private fun registerWindows() {
+ val command = javaLauncherCommand() + " \"%1\""
+ val base = "HKCU\\Software\\Classes\\$SCHEME"
+ listOf(
+ listOf("reg", "add", base, "/ve", "/d", "URL:FromChat Protocol", "/f"),
+ listOf("reg", "add", base, "/v", "URL Protocol", "/d", "", "/f"),
+ listOf("reg", "add", "$base\\shell\\open\\command", "/ve", "/d", command, "/f"),
+ ).forEach { args ->
+ ProcessBuilder(args).redirectErrorStream(true).start().waitFor()
+ }
+ }
+
+ private fun registerLinux() {
+ val appsDir = java.io.File(System.getProperty("user.home"), ".local/share/applications")
+ appsDir.mkdirs()
+ val desktop = java.io.File(appsDir, "fromchat-url-handler.desktop")
+ desktop.writeText(
+ """
+ |[Desktop Entry]
+ |Type=Application
+ |Name=FromChat URL Handler
+ |Exec=${javaLauncherCommand()} %u
+ |StartupNotify=false
+ |MimeType=x-scheme-handler/$SCHEME;
+ |NoDisplay=true
+ """.trimMargin(),
+ )
+ ProcessBuilder("xdg-mime", "default", desktop.name, "x-scheme-handler/$SCHEME")
+ .redirectErrorStream(true)
+ .start()
+ .waitFor()
+ ProcessBuilder("update-desktop-database", appsDir.absolutePath)
+ .redirectErrorStream(true)
+ .start()
+ .waitFor()
+ }
+}
+
+fun main(args: Array) {
+ if (!DesktopSingleInstance.acquireOrForward(args)) return
+ DesktopProtocolRegistration.registerBestEffort()
+ args.filter { DesktopDeepLinkBus.isFromChatUri(it) }
+ .forEach { DesktopDeepLinkBus.handleUri(it) }
+
+ application {
+ UtilsLibrary.init()
+ DesktopApplicationBootstrap.launchOnApplicationStart()
+
+ val windowState = rememberWindowState()
+ var windowVisible by remember { mutableStateOf(true) }
+ val trayState = rememberTrayState()
+ val trayIcon = remember { createTrayIconPainter() }
+ val traySupported = remember { java.awt.SystemTray.isSupported() }
+
+ DisposableEffect(trayState) {
+ DesktopNotifier.sink = { title, body ->
+ trayState.sendNotification(
+ Notification(title = title, message = body),
+ )
+ }
+ onDispose {
+ DesktopNotifier.sink = null
+ DesktopSingleInstance.release()
+ }
+ }
+
+ if (traySupported) {
+ Tray(
+ icon = trayIcon,
+ state = trayState,
+ tooltip = "FromChat",
+ onAction = { windowVisible = true },
+ menu = {
+ Item("Show") { windowVisible = true }
+ Item("Quit") { exitApplication() }
+ },
+ )
+ }
+
+ Window(
+ onCloseRequest = {
+ if (traySupported) {
+ windowVisible = false
+ AppForeground.setForeground(true)
+ } else {
+ exitApplication()
+ }
+ },
+ title = "FromChat",
+ state = windowState,
+ visible = windowVisible || !traySupported,
+ icon = trayIcon,
+ ) {
+ DisposableEffect(Unit) {
+ AppForeground.setForeground(true)
+ onDispose { }
+ }
+ App()
+ }
+ }
+}
+
+private fun createTrayIconPainter(): Painter = object : Painter() {
+ override val intrinsicSize: Size = Size(32f, 32f)
+
+ override fun DrawScope.onDraw() {
+ drawRect(Color(0xFF4484F4))
+ }
+}
diff --git a/app/ios/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/app/ios/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme
new file mode 100644
index 0000000..2d7fbc3
--- /dev/null
+++ b/app/ios/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/ios/iosApp/Info.plist b/app/ios/iosApp/Info.plist
index 1f411e8..1b3ffed 100644
--- a/app/ios/iosApp/Info.plist
+++ b/app/ios/iosApp/Info.plist
@@ -27,6 +27,14 @@
NSAllowsArbitraryLoads
+ NSMicrophoneUsageDescription
+ FromChat needs microphone access for voice and video calls.
+ NSCameraUsageDescription
+ FromChat needs camera access for video calls.
+ NSPhotoLibraryUsageDescription
+ FromChat needs photo library access so you can attach images to messages.
+ NSPhotoLibraryAddUsageDescription
+ FromChat needs permission to save images you export from chats.
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts
index 7f21a91..5c6235c 100644
--- a/app/shared/build.gradle.kts
+++ b/app/shared/build.gradle.kts
@@ -75,6 +75,8 @@ kotlin {
freeCompilerArgs.addAll("-Xexpect-actual-classes")
}
+ jvm()
+
listOf(
iosArm64(),
iosSimulatorArm64(),
@@ -107,8 +109,8 @@ kotlin {
implementation(libs.navigation.compose)
implementation(libs.compose.materialIconsExtended)
implementation(libs.haze)
- implementation(libs.haze.materials)
- implementation(libs.androidx.core.ktx)
+ implementation(libs.haze.blur)
+ implementation(libs.haze.blur.materials)
implementation(libs.androidx.lifecycle.runtime.compose)
// Serialization
@@ -142,6 +144,7 @@ kotlin {
}
androidMain.dependencies {
+ implementation(libs.androidx.core.ktx)
implementation(libs.markdown.renderer.m3)
implementation(libs.bouncycastle.bcprov)
implementation(libs.androidx.exifinterface)
@@ -164,6 +167,37 @@ kotlin {
implementation(libs.multiplatform.crypto.libsodium.bindings)
implementation(libs.sqldelight.driver.native)
}
+
+ jvmMain.dependencies {
+ implementation(libs.jetbrains.kotlinx.io.bytestring)
+ implementation(libs.jetbrains.kotlinx.coroutines.core)
+ implementation(libs.ktor.client.cio)
+ implementation(libs.multiplatform.crypto.libsodium.bindings)
+ implementation(libs.sqldelight.driver.sqlite)
+ implementation(libs.bouncycastle.bcprov)
+ // OpenJFX publishes empty jars without a classifier; declare every module
+ // with the host classifier and exclude transitive stubs.
+ val javafxVersion = libs.versions.openjfx.get()
+ val javafxClassifier = openjfxClassifier()
+ listOf("base", "graphics", "controls", "media", "web", "swing").forEach { module ->
+ implementation("org.openjfx:javafx-$module:$javafxVersion:$javafxClassifier") {
+ exclude(group = "org.openjfx")
+ }
+ }
+ }
+ }
+}
+
+private fun openjfxClassifier(): String {
+ val os = System.getProperty("os.name").lowercase()
+ val arch = System.getProperty("os.arch").lowercase()
+ val isArm = arch == "aarch64" || arch == "arm64"
+ return when {
+ os.contains("mac") && isArm -> "mac-aarch64"
+ os.contains("mac") -> "mac"
+ os.contains("win") -> "win"
+ isArm -> "linux-aarch64"
+ else -> "linux"
}
}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/KeepWebSocketAlive.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/KeepWebSocketAlive.android.kt
new file mode 100644
index 0000000..1709232
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/KeepWebSocketAlive.android.kt
@@ -0,0 +1,3 @@
+package ru.fromchat
+
+actual fun keepWebSocketAliveInBackground(): Boolean = false
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt
index 898fbb3..68b4d17 100644
--- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt
@@ -93,10 +93,10 @@ import androidx.compose.ui.zIndex
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.blur.blurEffect
+import dev.chrisbanes.haze.blur.materials.HazeMaterials
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 io.livekit.android.RoomOptions
import io.livekit.android.compose.local.RoomScope
@@ -275,7 +275,6 @@ private fun CallSlotSharedVideo(
}
}
-@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
actual fun CallMediaLayer(
connect: LiveKitConnectSession?,
@@ -551,7 +550,6 @@ actual fun CallMediaLayer(
}
}
-@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun CallRoomContent(
room: Room,
@@ -651,7 +649,7 @@ private fun CallRoomContent(
val participants = rememberParticipants(room).value
val local = room.localParticipant
val remote: Participant? = participants.firstOrNull { it != local }
- val hazeState = rememberHazeState(blurEnabled = showInCallControls)
+ val hazeState = rememberHazeState()
Box(
modifier = modifier
.fillMaxSize()
@@ -1179,7 +1177,7 @@ private fun CallPreviewCluster(
}
}
-@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalHazeMaterialsApi::class)
+@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun PreviewTile(
room: Room,
@@ -1198,7 +1196,7 @@ private fun PreviewTile(
val tileShape = RoundedCornerShape(16.dp)
// Only blur the *tile content* when the track isn't available yet.
// Do not blur the whole call scene; otherwise the opponent video stays blurred.
- val hazeState = rememberHazeState(blurEnabled = ref == null || !showVideo)
+ val hazeState = rememberHazeState()
Box(
modifier = Modifier
.size(104.dp, 138.dp)
@@ -1223,11 +1221,16 @@ private fun PreviewTile(
innerClipShape = tileShape,
)
} else {
+ val placeholderHazeStyle = HazeMaterials.thick()
Box(
Modifier
.fillMaxSize()
// Placeholder blur only for missing/disabled track.
- .hazeEffect(state = hazeState, style = HazeMaterials.thick())
+ .hazeEffect(state = hazeState) {
+ blurEffect {
+ style = placeholderHazeStyle
+ }
+ }
.background(MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.55f)),
)
}
@@ -1246,7 +1249,6 @@ private fun PreviewTile(
}
}
-@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun CallInlineControlBar(
room: Room,
@@ -1327,11 +1329,16 @@ private fun CallInlineControlBar(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
+ val controlsHazeStyle = HazeMaterials.thick()
Box(
modifier = Modifier
.fillMaxWidth(0.86f)
.clip(RoundedCornerShape(28.dp))
- .hazeEffect(state = hazeState, style = HazeMaterials.thick())
+ .hazeEffect(state = hazeState) {
+ blurEffect {
+ style = controlsHazeStyle
+ }
+ }
.background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.48f)),
) {
Row(
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/NotifyIncomingCall.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/NotifyIncomingCall.android.kt
new file mode 100644
index 0000000..5dab692
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/NotifyIncomingCall.android.kt
@@ -0,0 +1,3 @@
+package ru.fromchat.ui.calls
+
+internal actual fun notifyIncomingCall(title: String, body: String) = Unit
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.android.kt
index 900ca49..419ae6e 100644
--- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.android.kt
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.android.kt
@@ -40,4 +40,6 @@ actual fun areAppNotificationsEnabled(): Boolean {
} else {
true
}
-}
\ No newline at end of file
+}
+
+actual fun arePushNotificationsSupported(): Boolean = true
diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index 5107de8..deea7a9 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -59,6 +59,7 @@
Подтвердите, что вы человек, чтобы продолжить создание аккаунта.
Не удалось пройти проверку. Попробуйте ещё раз.
Яндекс ID
+ Завершите вход в браузере
Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.
Вход через Яндекс ID отменён или не удался.
Чаты
@@ -324,6 +325,7 @@
Откройте системные настройки и разрешите уведомления.
Push-уведомления
Они помогут вам узнать, если появилось новое сообщение пока приложение закрыто.
+ Недоступны на этой платформе. Новые сообщения видны только пока приложение открыто.
Устройства
Нет активных сессий
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index 744ee80..50ffb6e 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -66,6 +66,7 @@
Confirm you’re human to continue creating your account.
Captcha verification failed. Please try again.
Yandex ID
+ Complete sign-in in your browser
This server returned an unexpected Yandex app id. Update the app or contact support.
Yandex sign-in was cancelled or failed.
@@ -353,6 +354,7 @@
Open system notification settings to allow alerts.
Push notifications
These will let you know if you got a new message when the app is closed.
+ Not available on this platform. New messages are only visible while the app is open.
Devices
No active sessions
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/KeepWebSocketAlive.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/KeepWebSocketAlive.kt
new file mode 100644
index 0000000..e4abfb7
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/KeepWebSocketAlive.kt
@@ -0,0 +1,8 @@
+package ru.fromchat
+
+/**
+ * When true, the WebSocket reconnect loop does not pause while the UI is not
+ * "started" (e.g. desktop window hidden to tray). Mobile keeps the existing
+ * foreground-gated behavior.
+ */
+expect fun keepWebSocketAliveInBackground(): Boolean
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt
index 4a9665d..9d35abc 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt
@@ -29,6 +29,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.AppForeground
+import ru.fromchat.keepWebSocketAliveInBackground
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.UpdateSyncManager
@@ -117,6 +118,7 @@ object WebSocketManager {
}
private suspend fun awaitForeground() {
+ if (keepWebSocketAliveInBackground()) return
if (AppForeground.isInForeground.value) return
AppForeground.isInForeground.first { it }
}
@@ -369,7 +371,7 @@ object WebSocketManager {
}
fun onNetworkAvailable() {
- if (!AppForeground.isInForeground.value) return
+ if (!keepWebSocketAliveInBackground() && !AppForeground.isInForeground.value) return
val now = Clock.System.now().toEpochMilliseconds()
val prev = lastOnNetworkAvailableWallMs
@@ -393,7 +395,7 @@ object WebSocketManager {
* Restarts the reconnect loop immediately instead of waiting out exponential backoff.
*/
fun onServerLikelyReachable() {
- if (!AppForeground.isInForeground.value) return
+ if (!keepWebSocketAliveInBackground() && !AppForeground.isInForeground.value) return
if (session != null) return
if (ApiClient.token.isNullOrEmpty()) return
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCache.kt
index bcfe1f7..e4c4ea8 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCache.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCache.kt
@@ -30,36 +30,25 @@ object PublicChatProfileCache {
*/
fun hydrateFromDiskImmediate(instanceId: String): PublicChatProfile? {
val id = instanceId.trim()
- synchronized(this) {
- if (id.isEmpty()) {
- return _profile.value
- }
- if (loadedInstanceId == id && isCompleteProfile(_profile.value)) {
- return _profile.value
- }
- val previousInstanceId = loadedInstanceId
- loadedInstanceId = id
- val disk = PublicChatProfileCacheStore.getImmediate(id)
- _profile.value = when {
- disk != null -> disk
- previousInstanceId.isEmpty() -> _profile.value
- previousInstanceId != id -> null
- else -> _profile.value
- }
- return _profile.value
+ if (id.isEmpty()) return _profile.value
+ if (loadedInstanceId == id && isCompleteProfile(_profile.value)) return _profile.value
+ val previousInstanceId = loadedInstanceId
+ loadedInstanceId = id
+ val disk = PublicChatProfileCacheStore.getImmediate(id)
+ _profile.value = when {
+ disk != null -> disk
+ previousInstanceId.isEmpty() -> _profile.value
+ previousInstanceId != id -> null
+ else -> _profile.value
}
+ return _profile.value
}
fun put(profile: PublicChatProfile) {
- val instanceId: String
- synchronized(this) {
- _profile.value = profile
- instanceId = resolvedInstanceId()
- if (instanceId.isNotEmpty()) {
- loadedInstanceId = instanceId
- }
- }
+ _profile.value = profile
+ val instanceId = resolvedInstanceId()
if (instanceId.isNotEmpty()) {
+ loadedInstanceId = instanceId
runCatching { PublicChatProfileCacheStore.putImmediate(instanceId, profile) }
}
}
@@ -73,10 +62,8 @@ object PublicChatProfileCache {
}
suspend fun clear() {
- synchronized(this) {
- _profile.value = null
- loadedInstanceId = ""
- }
+ _profile.value = null
+ loadedInstanceId = ""
}
private fun resolvedInstanceId(): String =
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt
index 6a623bc..0e21a64 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt
@@ -54,6 +54,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.ToggleNavScrimEffect
+import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
@@ -101,7 +102,7 @@ fun DocumentScreen(
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val listState = rememberLazyListState()
- val hazeState = rememberHazeState(blurEnabled = true)
+ val hazeState = rememberHazeState()
LaunchedEffect(type) {
listState.scrollToItem(0)
@@ -116,6 +117,7 @@ fun DocumentScreen(
contentColor = MaterialTheme.colorScheme.onSurface,
contentWindowInsets = WindowInsets.navigationBars,
topBar = {
+ val topBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
MediumTopAppBar(
title = {
Text(
@@ -141,10 +143,11 @@ fun DocumentScreen(
),
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
- .hazeEffect(
- state = hazeState,
- style = rememberChatSurfaceContainerHazeStyle(),
- ),
+ .hazeEffect(state = hazeState) {
+ blurEffect {
+ style = topBarHazeStyle
+ }
+ },
)
},
) { innerPadding ->
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogStore.kt
index ade9925..4a841ab 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogStore.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/logging/AppLogStore.kt
@@ -12,6 +12,7 @@ import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
+import kotlinx.coroutines.runBlocking
import kotlin.time.Clock
import kotlin.time.Instant
@@ -52,8 +53,6 @@ object AppLogStore {
private var loadedFromDisk = false
private var nextEntryId = 0L
- private val writeLock = Any()
-
fun record(
level: AppLogLevel,
tag: String,
@@ -68,8 +67,14 @@ object AppLogStore {
message = message,
stackTrace = throwable?.stackTraceToString(),
)
- synchronized(writeLock) {
- appendEntry(entry)
+ if (mutex.tryLock()) {
+ try {
+ appendEntry(entry)
+ } finally {
+ mutex.unlock()
+ }
+ } else {
+ runBlocking { mutex.withLock { appendEntry(entry) } }
}
}
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 4d76a70..3d311ba 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
@@ -11,9 +11,13 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.width
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -24,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NamedNavArgument
@@ -33,6 +38,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import coil3.ImageLoader
@@ -41,8 +47,11 @@ import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.svg.SvgDecoder
import ru.fromchat.api.ApiClient
import com.pr0gramm3r101.utils.LocalSystemBarsVisibility
+import com.pr0gramm3r101.utils.WindowWidthSizeClass
+import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.rememberSystemBarsController
+import com.pr0gramm3r101.utils.widthSizeClass
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
@@ -55,6 +64,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.AppForeground
import ru.fromchat.Logger
+import ru.fromchat.keepWebSocketAliveInBackground
import ru.fromchat.api.DeferredStartupNetwork
import ru.fromchat.api.ProfileUpdateSync
import ru.fromchat.api.PublicChatProfileSync
@@ -116,6 +126,10 @@ import ru.fromchat.utils.NetworkConnectivity
val LocalNavController = compositionLocalOf { error("NavController not provided") }
+private fun isConversationChatRoute(route: String?): Boolean =
+ route == PublicChatNav.CHAT_ROUTE ||
+ (route != null && route.startsWith("dm/") && "/chat" in route)
+
private val rootNavTween = tween(durationMillis = 250, easing = FastOutSlowInEasing)
private fun rootNavEnterTransition(): EnterTransition =
@@ -281,9 +295,14 @@ fun App(
}
// Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]).
+ // Desktop keeps the socket alive while the process runs (tray / hidden window).
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
fun syncForeground() {
+ if (keepWebSocketAliveInBackground()) {
+ AppForeground.setForeground(true)
+ return
+ }
AppForeground.setForeground(
lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
)
@@ -301,7 +320,11 @@ fun App(
}
}
}
- Lifecycle.Event.ON_STOP -> AppForeground.setForeground(false)
+ Lifecycle.Event.ON_STOP -> {
+ if (!keepWebSocketAliveInBackground()) {
+ AppForeground.setForeground(false)
+ }
+ }
else -> {}
}
}
@@ -413,16 +436,37 @@ fun App(
LocalSystemBarsVisibility provides rememberSystemBarsController()
) {
if (startDestination != null) {
+ val currentEntry by navController.currentBackStackEntryAsState()
+ val showConversationListDetail =
+ currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.EXPANDED &&
+ isConversationChatRoute(currentEntry?.destination?.route)
+
ScreenSurface {
Box(Modifier.fillMaxSize()) {
- NavHost(
- navController = navController,
- startDestination = startDestination!!,
- enterTransition = { rootNavEnterTransition() },
- exitTransition = { rootNavExitTransition() },
- popEnterTransition = { rootNavPopEnterTransition() },
- popExitTransition = { rootNavPopExitTransition() },
- ) {
+ Row(Modifier.fillMaxSize()) {
+ if (showConversationListDetail) {
+ Box(
+ modifier = Modifier
+ .width(360.dp)
+ .fillMaxHeight(),
+ ) {
+ MainScreen(
+ sharedTransitionScope = this@SharedTransitionLayout,
+ snackbarHostState = profileLookupSnackbarHostState,
+ listPaneOnly = true,
+ )
+ }
+ VerticalDivider()
+ }
+ Box(Modifier.weight(1f).fillMaxSize()) {
+ NavHost(
+ navController = navController,
+ startDestination = startDestination!!,
+ enterTransition = { rootNavEnterTransition() },
+ exitTransition = { rootNavExitTransition() },
+ popEnterTransition = { rootNavPopEnterTransition() },
+ popExitTransition = { rootNavPopExitTransition() },
+ ) {
composable("serverConfig") {
ServerConfigScreen()
}
@@ -574,7 +618,7 @@ fun App(
),
) { entry ->
val focusField = EditProfileFocusField.fromArg(
- entry.arguments?.getString(ProfileRoutes.ARG_FOCUS),
+ entry.savedStateHandle.get(ProfileRoutes.ARG_FOCUS),
)
EditProfileScreen(
onBack = { navController.navigateUp() },
@@ -733,8 +777,10 @@ fun App(
ApiClient.onAuthError = null
}
}
+ }
+ }
- CallOverlay(Modifier.fillMaxSize())
+ CallOverlay(Modifier.fillMaxSize())
}
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/LiveKitCallWebPage.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/LiveKitCallWebPage.kt
new file mode 100644
index 0000000..fee462c
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/LiveKitCallWebPage.kt
@@ -0,0 +1,221 @@
+package ru.fromchat.ui.calls
+
+/**
+ * Shared LiveKit JS page for iOS [WKWebView] and desktop JavaFX [WebView].
+ * Native LiveKit SDKs are used on Android; desktop has no JVM SDK.
+ */
+internal object LiveKitCallWebPage {
+ private const val LIVEKIT_CDN =
+ "https://cdn.jsdelivr.net/npm/livekit-client@2.15.4/dist/livekit-client.umd.min.js"
+
+ val html: String = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+""".trimIndent()
+
+ fun connectScript(serverUrl: String, token: String): String {
+ val url = jsonStringLiteral(serverUrl)
+ val tok = jsonStringLiteral(token)
+ return "window.fromChatConnect($url, $tok);"
+ }
+
+ fun setMicScript(enabled: Boolean): String =
+ "window.FromChatCallCtl && window.FromChatCallCtl.setMicEnabled(${if (enabled) "true" else "false"});"
+
+ fun setCamScript(enabled: Boolean): String =
+ "window.FromChatCallCtl && window.FromChatCallCtl.setCamEnabled(${if (enabled) "true" else "false"});"
+
+ fun disconnectScript(): String =
+ "window.FromChatCallCtl && window.FromChatCallCtl.disconnect();"
+
+ private fun jsonStringLiteral(value: String): String = buildString {
+ append('"')
+ value.forEach { ch ->
+ when (ch) {
+ '\\' -> append("\\\\")
+ '"' -> append("\\\"")
+ '\n' -> append("\\n")
+ '\r' -> append("\\r")
+ '\t' -> append("\\t")
+ else -> {
+ if (ch.code < 0x20) {
+ append("\\u")
+ append(ch.code.toString(16).padStart(4, '0'))
+ } else {
+ append(ch)
+ }
+ }
+ }
+ }
+ append('"')
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/NotifyIncomingCall.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/NotifyIncomingCall.kt
new file mode 100644
index 0000000..2365684
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/NotifyIncomingCall.kt
@@ -0,0 +1,4 @@
+package ru.fromchat.ui.calls
+
+/** Best-effort OS notification for an incoming call (desktop tray; no-op elsewhere). */
+internal expect fun notifyIncomingCall(title: String, body: String)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/WebCallInCallControls.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/WebCallInCallControls.kt
new file mode 100644
index 0000000..c22f8e8
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/WebCallInCallControls.kt
@@ -0,0 +1,107 @@
+package ru.fromchat.ui.calls
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CallEnd
+import androidx.compose.material.icons.filled.Mic
+import androidx.compose.material.icons.filled.MicOff
+import androidx.compose.material.icons.filled.Videocam
+import androidx.compose.material.icons.filled.VideocamOff
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import ru.fromchat.Res
+import ru.fromchat.cd_call_camera
+import ru.fromchat.cd_call_end
+import ru.fromchat.cd_call_mic
+
+/** Mic / camera / end controls for WebView-based LiveKit call UIs (iOS + desktop). */
+@Composable
+internal fun WebCallInCallControls(
+ micOn: Boolean,
+ camOn: Boolean,
+ onMicToggle: () -> Unit,
+ onCamToggle: () -> Unit,
+ onEndCall: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 28.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ FilledTonalIconButton(
+ onClick = onMicToggle,
+ modifier = Modifier.size(52.dp),
+ colors = IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = if (micOn) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ MaterialTheme.colorScheme.errorContainer
+ },
+ contentColor = if (micOn) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onErrorContainer
+ },
+ ),
+ ) {
+ Icon(
+ imageVector = if (micOn) Icons.Filled.Mic else Icons.Filled.MicOff,
+ contentDescription = stringResource(Res.string.cd_call_mic),
+ )
+ }
+ Spacer(Modifier.size(10.dp))
+ FilledTonalIconButton(
+ onClick = onCamToggle,
+ modifier = Modifier.size(52.dp),
+ colors = IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = if (camOn) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ MaterialTheme.colorScheme.errorContainer
+ },
+ contentColor = if (camOn) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onErrorContainer
+ },
+ ),
+ ) {
+ Icon(
+ imageVector = if (camOn) Icons.Filled.Videocam else Icons.Filled.VideocamOff,
+ contentDescription = stringResource(Res.string.cd_call_camera),
+ )
+ }
+ Spacer(Modifier.size(18.dp))
+ FilledIconButton(
+ onClick = onEndCall,
+ modifier = Modifier.size(56.dp),
+ shape = CircleShape,
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.CallEnd,
+ contentDescription = stringResource(Res.string.cd_call_end),
+ )
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt
index 0184c39..003f9be 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt
@@ -1,5 +1,3 @@
-@file:OptIn(ExperimentalHazeMaterialsApi::class)
-
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent
@@ -68,10 +66,10 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.pr0gramm3r101.utils.crypto.Base64
+import dev.chrisbanes.haze.blur.blurEffect
+import dev.chrisbanes.haze.blur.materials.HazeMaterials
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -122,7 +120,6 @@ internal fun isImageFilename(name: String): Boolean =
name.endsWith(".png", true) || name.endsWith(".jpg", true) ||
name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true)
-@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun AttachmentPreview(
file: DmFile?,
@@ -313,7 +310,7 @@ fun AttachmentPreview(
}
}
-@OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class)
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ChatImageTileContent(
messageId: Int,
@@ -458,7 +455,8 @@ private fun ChatImageTileContent(
val imageContentScale = ContentScale.Crop
val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f)
- val thumbHazeState = rememberHazeState(blurEnabled = true)
+ val thumbHazeState = rememberHazeState()
+ val thumbOverlayHazeStyle = HazeMaterials.thin()
LaunchedEffect(messageId, fileIndex, cacheClientId) {
DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri ->
@@ -793,10 +791,11 @@ private fun ChatImageTileContent(
Box(
modifier = Modifier
.fillMaxSize()
- .hazeEffect(
- state = thumbHazeState,
- style = HazeMaterials.thin(),
- ),
+ .hazeEffect(state = thumbHazeState) {
+ blurEffect {
+ style = thumbOverlayHazeStyle
+ }
+ },
)
}
}
@@ -918,7 +917,6 @@ private fun ChatImageTileContent(
}
}
-@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun DownloadCancelledImageOverlay(
isAuthor: Boolean,
@@ -926,9 +924,14 @@ private fun DownloadCancelledImageOverlay(
modifier: Modifier = Modifier,
) {
val scrim = MaterialTheme.colorScheme.scrim.copy(alpha = 0.38f)
+ val overlayHazeStyle = HazeMaterials.thin()
Box(
modifier = modifier
- .hazeEffect(style = HazeMaterials.thin())
+ .hazeEffect {
+ blurEffect {
+ style = overlayHazeStyle
+ }
+ }
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
@@ -958,7 +961,6 @@ private fun DownloadCancelledImageOverlay(
}
}
-@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun UploadingImageOverlay(
model: String?,
@@ -969,12 +971,17 @@ private fun UploadingImageOverlay(
onCancelUpload: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
+ val overlayHazeStyle = HazeMaterials.thin()
Box(modifier = modifier) {
Box(
modifier = Modifier
.matchParentSize()
.clip(clipShape)
- .hazeEffect(style = HazeMaterials.thin())
+ .hazeEffect {
+ blurEffect {
+ style = overlayHazeStyle
+ }
+ }
) {
when {
previewBitmap != null -> {
@@ -1084,7 +1091,6 @@ internal fun ExpressiveUploadIndicator(
}
}
-@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun PendingImageContent(
uri: String,
@@ -1260,10 +1266,15 @@ private fun CorruptedImagePlaceholder(
modifier: Modifier = Modifier,
clipShape: RoundedCornerShape = attachmentImageCornerShape(isAuthor = false),
) {
+ val overlayHazeStyle = HazeMaterials.thin()
Box(
modifier = modifier
.clip(clipShape)
- .hazeEffect(style = HazeMaterials.thin()),
+ .hazeEffect {
+ blurEffect {
+ style = overlayHazeStyle
+ }
+ },
contentAlignment = Alignment.Center,
) {
Icon(
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt
index 57b91cd..2e5c06f 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt
@@ -72,6 +72,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -244,6 +245,7 @@ fun ChatInput(
isReadOnly: Boolean = false,
onReadOnlyMessageClick: () -> Unit = {},
snackbarHostState: SnackbarHostState? = null,
+ hazeBlurEnabled: Boolean = true,
) {
val composerHazeStyle = rememberChatSurfaceContainerHazeStyle()
val scope = rememberCoroutineScope()
@@ -313,7 +315,12 @@ fun ChatInput(
modifier = Modifier
.fillMaxWidth()
.clip(pillShape)
- .hazeEffect(state = hazeState, style = composerHazeStyle)
+ .hazeEffect(state = hazeState) {
+ blurEffect {
+ blurEnabled = hazeBlurEnabled
+ style = composerHazeStyle
+ }
+ }
.clickable(enabled = true) { onReadOnlyMessageClick() },
) {
Text(
@@ -344,7 +351,12 @@ fun ChatInput(
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer, pillShape)
.clip(pillShape)
- .hazeEffect(state = hazeState, style = composerHazeStyle),
+ .hazeEffect(state = hazeState) {
+ blurEffect {
+ blurEnabled = hazeBlurEnabled
+ style = composerHazeStyle
+ }
+ },
) {
AnimatedPreviewBar(replyTo) { reply ->
val replySubtitle = if (reply.isContentCorrupted) {
@@ -550,10 +562,14 @@ fun ChatInput(
modifier = Modifier
.fillMaxSize()
.background(actionBackground)
- .hazeEffect(state = hazeState, style = composerHazeStyle) {
- // Keep the node in the tree in both directions; fading alpha avoids the
- // frosted layer popping on over the color when send -> voice.
- alpha = hazeOverlayAlpha
+ .hazeEffect(state = hazeState) {
+ blurEffect {
+ // Keep the node in the tree in both directions; fading alpha avoids the
+ // frosted layer popping on over the color when send -> voice.
+ blurEnabled = hazeBlurEnabled
+ style = composerHazeStyle
+ alpha = hazeOverlayAlpha
+ }
},
)
AnimatedContent(
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 53fd231..9b9520b 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
@@ -79,11 +79,11 @@ import ru.fromchat.utils.rememberRegistrationDateFormatStrings
import com.pr0gramm3r101.utils.resetFocus
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import dev.chrisbanes.haze.HazeState
-import dev.chrisbanes.haze.HazeProgressive
+import dev.chrisbanes.haze.blur.HazeProgressive
+import dev.chrisbanes.haze.blur.blurEffect
+import dev.chrisbanes.haze.blur.materials.HazeMaterials
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.math.roundToInt
import kotlinx.coroutines.Dispatchers
@@ -157,7 +157,7 @@ import ru.fromchat.utils.rememberLastSeenFormatStrings
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
panel: ChatPanel,
@@ -426,9 +426,8 @@ fun ChatScreen(
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val profileUserId = panelState.profileUserId
- val hazeState = rememberHazeState(
- blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
- )
+ val hazeState = rememberHazeState()
+ val hazeBlurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
val statusMap by UserStatusStore.status.collectAsState()
@@ -816,16 +815,21 @@ fun ChatScreen(
// floating header Box only; avoids extra top inset on the fade / list and duplicate “padding”.
contentWindowInsets = WindowInsets.navigationBars,
bottomBar = {
+ val bottomBarHazeStyle = HazeMaterials.thin()
Column(
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
- .hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
- progressive = HazeProgressive.verticalGradient(
- startIntensity = 0f,
- endIntensity = 1f,
- )
+ .hazeEffect(state = hazeState) {
+ blurEffect {
+ blurEnabled = hazeBlurEnabled
+ style = bottomBarHazeStyle
+ progressive = HazeProgressive.verticalGradient(
+ startIntensity = 0f,
+ endIntensity = 1f,
+ )
+ }
}
) {
if (peerDeleted && dmRecipientId != null) {
@@ -1045,6 +1049,7 @@ fun ChatScreen(
hazeState = hazeState,
supportsAttachments = panel.supportsAttachments,
isReadOnly = isReadOnly,
+ hazeBlurEnabled = hazeBlurEnabled,
onReadOnlyMessageClick = {
if (isReadOnly) {
showSuspendedSupportSheet = true
@@ -1383,11 +1388,13 @@ fun ChatScreen(
},
contentDescription = scrollToBottomCd,
hazeState = hazeState,
+ hazeBlurEnabled = hazeBlurEnabled,
)
}
ChatTopBar(
hazeState = hazeState,
+ hazeBlurEnabled = hazeBlurEnabled,
onBack = {
runNav { navController.navigateUp() }
},
@@ -1583,6 +1590,7 @@ private fun ChatScrollToBottomButton(
contentDescription: String,
hazeState: HazeState,
modifier: Modifier = Modifier,
+ hazeBlurEnabled: Boolean = true,
) {
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
Box(
@@ -1598,7 +1606,12 @@ private fun ChatScrollToBottomButton(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
- .hazeEffect(state = hazeState, style = hazeStyle),
+ .hazeEffect(state = hazeState) {
+ blurEffect {
+ blurEnabled = hazeBlurEnabled
+ style = hazeStyle
+ }
+ },
)
Icon(
imageVector = Icons.Rounded.KeyboardArrowDown,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt
index 800cf90..3cebf86 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt
@@ -62,8 +62,9 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.conditional
import dev.chrisbanes.haze.HazeState
-import dev.chrisbanes.haze.HazeStyle
-import dev.chrisbanes.haze.HazeTint
+import dev.chrisbanes.haze.blur.HazeBlurStyle
+import dev.chrisbanes.haze.blur.HazeColorEffect
+import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
@@ -325,6 +326,7 @@ fun ChatTopBar(
callContentDescription: String,
titleChrome: @Composable () -> Unit,
modifier: Modifier = Modifier,
+ hazeBlurEnabled: Boolean = true,
) {
val bottomCornerRadius = ChatFloatingHeaderBottomArcRadius
// [BottomInsetTopBarShape] draws the arc in the strip from y = (content height) .. (content + r);
@@ -370,6 +372,7 @@ fun ChatTopBar(
val layoutHeight = topBarPlaceable.height + arcExtentPx
val bgPlaceable = subcompose("background") {
+ val hazeStyle = rememberChatSurfaceContainerHazeStyle()
Box(
modifier = Modifier
.fillMaxSize()
@@ -379,7 +382,12 @@ fun ChatTopBar(
}
)
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
- .hazeEffect(state = hazeState, style = rememberChatSurfaceContainerHazeStyle()),
+ .hazeEffect(state = hazeState) {
+ blurEffect {
+ blurEnabled = hazeBlurEnabled
+ style = hazeStyle
+ }
+ },
)
}.first().measure(Constraints.fixed(layoutWidth, layoutHeight))
@@ -447,18 +455,21 @@ private class BottomInsetTopBarShape(private val cornerRadius: Dp) : Shape {
}
/**
- * [HazeStyle] for chat chrome using only [androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainer]
- * (24dp blur + luminance-scaled tint, slightly denser than old “thin” defaults), without [dev.chrisbanes.haze.materials.HazeMaterials].
+ * [HazeBlurStyle] for chat chrome using only [androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainer]
+ * (24dp blur + luminance-scaled tint, slightly denser than old “thin” defaults), without
+ * [dev.chrisbanes.haze.blur.materials.HazeMaterials].
*/
@Composable
-fun rememberChatSurfaceContainerHazeStyle(): HazeStyle {
+fun rememberChatSurfaceContainerHazeStyle(): HazeBlurStyle {
val surface = MaterialTheme.colorScheme.surfaceContainer
return remember(surface) {
- HazeStyle(
+ HazeBlurStyle(
blurRadius = 24.dp,
backgroundColor = surface,
- tint = HazeTint(surface.copy(alpha = if (surface.luminance() >= 0.5f) 0.74f else 0.79f)),
+ colorEffect = HazeColorEffect.tint(
+ surface.copy(alpha = if (surface.luminance() >= 0.5f) 0.74f else 0.79f),
+ ),
)
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt
index df00a3b..422e806 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt
@@ -534,7 +534,7 @@ fun MessageItem(
BoxWithConstraints(
modifier = Modifier.weight(1f, fill = false)
) {
- val maxBubbleWidth = maxWidth * 0.7f
+ val maxBubbleWidth = minOf(maxWidth * 0.7f, 420.dp)
Column(
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.kt
index a4c13a7..51e3cb4 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.kt
@@ -13,7 +13,7 @@ data class SelectedAttachment(
/**
* Platform-specific image picker. Returns a launch function.
* On Android: PickVisualMedia (images only, multiple).
- * On iOS: Placeholder (no-op until Phase 4).
+ * On iOS: PHPickerViewController (images only, multiple).
*/
@Composable
expect fun rememberImagePicker(onResult: (List) -> Unit): () -> Unit
@@ -21,7 +21,7 @@ expect fun rememberImagePicker(onResult: (List) -> Unit): () -> Unit
/**
* Platform-specific file picker. Returns a launch function.
* On Android: OpenDocument/OpenMultipleDocuments (SAF).
- * On iOS: Placeholder (no-op until Phase 4).
+ * On iOS: UIDocumentPickerViewController (multiple, asCopy).
*/
@Composable
expect fun rememberFilePicker(onResult: (List) -> Unit): () -> Unit
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt
index dc84a76..b00ba5a 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt
@@ -83,12 +83,12 @@ import androidx.compose.ui.zIndex
import androidx.graphics.shapes.Morph
import androidx.graphics.shapes.RoundedPolygon
import com.pr0gramm3r101.utils.LastAnchoredBottomArrangement
-import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.blur.HazeProgressive
+import dev.chrisbanes.haze.blur.blurEffect
+import dev.chrisbanes.haze.blur.materials.HazeMaterials
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
@@ -333,7 +333,6 @@ fun MorphedExpressiveStepButton(
@OptIn(
ExperimentalAnimationApi::class,
ExperimentalMaterial3Api::class,
- ExperimentalHazeMaterialsApi::class,
)
@Composable
fun ExpressiveStepFlowScaffold(
@@ -549,6 +548,7 @@ fun ExpressiveStepFlowScaffold(
@Composable
fun HazeTopBar(hazeState: HazeState) {
+ val topBarHazeStyle = HazeMaterials.thin()
TopAppBar(
title = {},
navigationIcon = {
@@ -566,11 +566,14 @@ fun ExpressiveStepFlowScaffold(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
- modifier = Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
- progressive = HazeProgressive.verticalGradient(
- startIntensity = 1f,
- endIntensity = 0f,
- )
+ modifier = Modifier.hazeEffect(state = hazeState) {
+ blurEffect {
+ style = topBarHazeStyle
+ progressive = HazeProgressive.verticalGradient(
+ startIntensity = 1f,
+ endIntensity = 0f,
+ )
+ }
},
)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt
index 6dacbc8..a4ed102 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt
@@ -12,30 +12,37 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
-import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
-import dev.chrisbanes.haze.HazeStyle
+import dev.chrisbanes.haze.blur.HazeBlurStyle
+import dev.chrisbanes.haze.blur.HazeProgressive
+import dev.chrisbanes.haze.blur.blurEffect
+import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
-import dev.chrisbanes.haze.materials.HazeMaterials
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
-@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun HazeBottomBar(
hazeState: HazeState,
modifier: Modifier = Modifier,
- hazeStyle: HazeStyle? = null,
+ hazeStyle: HazeBlurStyle? = null,
content: @Composable () -> Unit,
) {
+ val resolvedStyle = hazeStyle ?: HazeMaterials.thin()
val effectModifier = if (hazeStyle != null) {
- Modifier.hazeEffect(state = hazeState, style = hazeStyle)
+ Modifier.hazeEffect(state = hazeState) {
+ blurEffect {
+ style = resolvedStyle
+ }
+ }
} else {
- Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
- progressive = HazeProgressive.verticalGradient(
- startIntensity = 0f,
- endIntensity = 1f,
- )
+ Modifier.hazeEffect(state = hazeState) {
+ blurEffect {
+ style = resolvedStyle
+ progressive = HazeProgressive.verticalGradient(
+ startIntensity = 0f,
+ endIntensity = 1f,
+ )
+ }
}
}
Column(
@@ -57,14 +64,13 @@ fun HazeBottomBar(
}
}
-@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun HazeActionButton(
onClick: () -> Unit,
hazeState: HazeState,
modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier,
- hazeStyle: HazeStyle? = null,
+ hazeStyle: HazeBlurStyle? = null,
enabled: Boolean = true,
loading: Boolean = false,
interactionSource: MutableInteractionSource? = null,
@@ -80,4 +86,4 @@ fun HazeActionButton(
content = content,
)
}
-}
\ No newline at end of file
+}
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 dce726b..cecd133 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
@@ -6,9 +6,11 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
@@ -44,14 +46,18 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
+import com.pr0gramm3r101.utils.NavigationItem
+import com.pr0gramm3r101.utils.SimpleNavigationRail
+import com.pr0gramm3r101.utils.WindowWidthSizeClass
+import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
+import com.pr0gramm3r101.utils.widthSizeClass
import dev.chrisbanes.haze.HazeState
-import dev.chrisbanes.haze.HazeStyle
-import dev.chrisbanes.haze.HazeTint
+import dev.chrisbanes.haze.blur.HazeBlurStyle
+import dev.chrisbanes.haze.blur.HazeColorEffect
+import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
-import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
@@ -61,6 +67,7 @@ import ru.fromchat.contacts
import ru.fromchat.profile
import ru.fromchat.settings
import ru.fromchat.ui.LocalNavController
+import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController
@@ -75,12 +82,17 @@ private const val PAGE_SETTINGS = 2
private const val PAGE_PROFILE = 3
private const val PAGE_COUNT = 4
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
+/**
+ * @param listPaneOnly When true, renders only the chats list (no nav rail/bar) for use as the
+ * list side of an expanded list–detail conversation layout.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
snackbarHostState: SnackbarHostState? = null,
+ listPaneOnly: Boolean = false,
) {
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
val navController = LocalNavController.current
@@ -90,11 +102,14 @@ fun MainScreen(
)
val scope = rememberCoroutineScope()
val density = LocalDensity.current
- val navBarHazeState = rememberHazeState(blurEnabled = true)
+ val navBarHazeState = rememberHazeState()
val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
- val contextMenuHazeState = rememberHazeState(blurEnabled = true)
+ val contextMenuHazeState = rememberHazeState()
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
+ val useNavigationRail = !listPaneOnly &&
+ currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
+
val selectedPage = pagerState.currentPage
val isChatsPage = selectedPage == PAGE_CHATS
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
@@ -102,46 +117,83 @@ fun MainScreen(
val statusBarTopDp = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
var bottomChromeHeightDp by remember { mutableStateOf(0.dp) }
- val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp) {
+ val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp, useNavigationRail, listPaneOnly) {
MainChromeInsets(
top = statusBarTopDp,
- bottom = bottomChromeHeightDp,
+ bottom = if (useNavigationRail || listPaneOnly) 0.dp else bottomChromeHeightDp,
)
}
- BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .hazeSource(contextMenuHazeState),
- ) {
- Scaffold(
- snackbarHost = {
- FromChatSnackbarHost(
- hostState = effectiveSnackbarHostState,
- modifier = Modifier.padding(bottom = mainChromeInsets.bottom),
- )
- },
- containerColor = Color.Transparent,
- contentWindowInsets = WindowInsets(0, 0, 0, 0),
+ val chatsLabel = stringResource(Res.string.chats)
+ val contactsLabel = stringResource(Res.string.contacts)
+ val settingsLabel = stringResource(Res.string.settings)
+ val profileLabel = stringResource(Res.string.profile)
+
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ Row(Modifier.fillMaxSize()) {
+ if (useNavigationRail) {
+ SimpleNavigationRail(
+ selectedItem = selectedPage,
+ modifier = Modifier.fillMaxHeight(),
+ NavigationItem(
+ name = chatsLabel,
+ selectedIcon = Icons.AutoMirrored.Filled.Chat,
+ onClick = {
+ scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
+ },
+ ),
+ NavigationItem(
+ name = contactsLabel,
+ selectedIcon = Icons.Filled.Contacts,
+ onClick = {
+ scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
+ },
+ ),
+ NavigationItem(
+ name = settingsLabel,
+ selectedIcon = Icons.Filled.Settings,
+ onClick = {
+ scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
+ },
+ ),
+ NavigationItem(
+ name = profileLabel,
+ selectedIcon = Icons.Filled.Person,
+ onClick = {
+ scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
+ },
+ ),
+ )
+ }
+
+ Box(
modifier = Modifier
- .fillMaxSize()
- .imePadding(),
+ .weight(1f)
+ .fillMaxHeight()
+ .hazeSource(contextMenuHazeState),
) {
- CompositionLocalProvider(LocalMainChromeInsets provides mainChromeInsets) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .hazeSource(navBarHazeState),
- ) {
- HorizontalPager(
- state = pagerState,
- modifier = Modifier.fillMaxSize(),
- beyondViewportPageCount = 1,
- ) { page ->
- when (page) {
- PAGE_CHATS -> ChatsTab(
- isVisible = isChatsPage,
+ Scaffold(
+ snackbarHost = {
+ FromChatSnackbarHost(
+ hostState = effectiveSnackbarHostState,
+ modifier = Modifier.padding(bottom = mainChromeInsets.bottom),
+ )
+ },
+ containerColor = Color.Transparent,
+ contentWindowInsets = WindowInsets(0, 0, 0, 0),
+ modifier = Modifier
+ .fillMaxSize()
+ .imePadding(),
+ ) {
+ CompositionLocalProvider(LocalMainChromeInsets provides mainChromeInsets) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .hazeSource(navBarHazeState),
+ ) {
+ if (listPaneOnly) {
+ ChatsTab(
+ isVisible = true,
onOpenSearch = {
navController.navigate("search/conversations")
},
@@ -149,81 +201,118 @@ fun MainScreen(
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
- PAGE_CONTACTS -> ContactsTab()
- PAGE_SETTINGS -> SettingsTab()
- PAGE_PROFILE -> {
- ProfileScreen(
- userId = ApiClient.user?.id,
- onBack = {},
- onChat = { _ -> },
- modifier = Modifier
- .fillMaxSize()
- .mainPagerBottomInset(),
- onOpenSettings = {
- scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
- },
- )
+ } else {
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier.fillMaxSize(),
+ beyondViewportPageCount = 1,
+ ) { page ->
+ when (page) {
+ PAGE_CHATS -> ChatsTab(
+ isVisible = isChatsPage,
+ onOpenSearch = {
+ navController.navigate("search/conversations")
+ },
+ chatContextMenuOverlay = chatContextMenuOverlay,
+ sharedTransitionScope = sharedTransitionScope,
+ animatedVisibilityScope = animatedVisibilityScope,
+ )
+ PAGE_CONTACTS -> ContactsTab()
+ PAGE_SETTINGS -> SettingsTab()
+ PAGE_PROFILE -> {
+ ProfileScreen(
+ userId = ApiClient.user?.id,
+ onBack = {},
+ onChat = { _ -> },
+ modifier = Modifier
+ .fillMaxSize()
+ .mainPagerBottomInset(),
+ onOpenSettings = {
+ scope.launch {
+ pagerState.animateScrollToPage(PAGE_SETTINGS)
+ }
+ },
+ )
+ }
+ else -> Unit
+ }
}
- else -> Unit
}
}
}
}
- }
- Column(
- modifier = Modifier
- .align(Alignment.BottomCenter)
- .fillMaxWidth()
- .zIndex(1f)
- .imePadding()
- .onSizeChanged { size ->
- val measured = with(density) { size.height.toDp() }
- if (measured != bottomChromeHeightDp) {
- bottomChromeHeightDp = measured
+ if (!useNavigationRail && !listPaneOnly) {
+ Column(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .zIndex(1f)
+ .imePadding()
+ .onSizeChanged { size ->
+ val measured = with(density) { size.height.toDp() }
+ if (measured != bottomChromeHeightDp) {
+ bottomChromeHeightDp = measured
+ }
+ }
+ .background(MaterialTheme.colorScheme.surfaceContainer)
+ .hazeEffect(state = navBarHazeState) {
+ blurEffect {
+ style = navBarHazeStyle
+ }
+ },
+ ) {
+ NavigationBar(
+ modifier = Modifier.fillMaxWidth(),
+ containerColor = Color.Transparent,
+ tonalElevation = 0.dp,
+ windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
+ ) {
+ NavigationBarItem(
+ selected = selectedPage == PAGE_CHATS,
+ onClick = {
+ scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
+ },
+ label = { Text(chatsLabel) },
+ icon = {
+ Icon(
+ Icons.AutoMirrored.Filled.Chat,
+ contentDescription = null,
+ )
+ },
+ )
+ NavigationBarItem(
+ selected = selectedPage == PAGE_CONTACTS,
+ onClick = {
+ scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
+ },
+ label = { Text(contactsLabel) },
+ icon = {
+ Icon(Icons.Filled.Contacts, contentDescription = null)
+ },
+ )
+ NavigationBarItem(
+ selected = selectedPage == PAGE_SETTINGS,
+ onClick = {
+ scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
+ },
+ label = { Text(settingsLabel) },
+ icon = {
+ Icon(Icons.Filled.Settings, contentDescription = null)
+ },
+ )
+ NavigationBarItem(
+ selected = selectedPage == PAGE_PROFILE,
+ onClick = {
+ scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
+ },
+ label = { Text(profileLabel) },
+ icon = {
+ Icon(Icons.Filled.Person, contentDescription = null)
+ },
+ )
}
}
- .background(MaterialTheme.colorScheme.surfaceContainer)
- .hazeEffect(state = navBarHazeState, style = navBarHazeStyle),
- ) {
- NavigationBar(
- modifier = Modifier.fillMaxWidth(),
- containerColor = Color.Transparent,
- tonalElevation = 0.dp,
- windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
- ) {
- NavigationBarItem(
- selected = selectedPage == PAGE_CHATS,
- onClick = {
- scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
- },
- label = { Text(stringResource(Res.string.chats)) },
- icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) },
- )
- NavigationBarItem(
- selected = selectedPage == PAGE_CONTACTS,
- onClick = {
- scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
- },
- label = { Text(stringResource(Res.string.contacts)) },
- icon = { Icon(Icons.Filled.Contacts, contentDescription = null) },
- )
- NavigationBarItem(
- selected = selectedPage == PAGE_SETTINGS,
- onClick = {
- scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
- },
- label = { Text(stringResource(Res.string.settings)) },
- icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
- )
- NavigationBarItem(
- selected = selectedPage == PAGE_PROFILE,
- onClick = {
- scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
- },
- label = { Text(stringResource(Res.string.profile)) },
- icon = { Icon(Icons.Filled.Person, contentDescription = null) },
- )
}
}
}
@@ -249,7 +338,6 @@ fun MainScreen(
}
}
-@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun ChatContextMenuBlurLayer(
hazeState: HazeState,
@@ -260,15 +348,16 @@ private fun ChatContextMenuBlurLayer(
if (blurRadius <= 0.dp) return
Box(
- modifier = modifier.hazeEffect(
- state = hazeState,
- style = HazeStyle(
- blurRadius = blurRadius,
- tints = emptyList(),
- backgroundColor = Color.Transparent,
- noiseFactor = 0f,
- fallbackTint = HazeTint(Color.Transparent),
- ),
- ),
+ modifier = modifier.hazeEffect(state = hazeState) {
+ blurEffect {
+ style = HazeBlurStyle(
+ blurRadius = blurRadius,
+ colorEffects = emptyList(),
+ backgroundColor = Color.Transparent,
+ noiseFactor = 0f,
+ fallbackColorEffect = HazeColorEffect.tint(Color.Transparent),
+ )
+ }
+ },
)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
index f9ff83a..64f9dd4 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
@@ -59,10 +59,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.ui.graphics.Color
import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.blur.blurEffect
+import dev.chrisbanes.haze.blur.materials.HazeMaterials
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 androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -340,20 +340,23 @@ private fun chatsTopAppBarColors(blurReveal: Float) = TopAppBarDefaults.topAppBa
scrolledContainerColor = Color.Transparent,
)
-@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun ChatsTopBarHazeBackdrop(
hazeState: HazeState,
blurReveal: Float,
+ blurEnabled: Boolean,
modifier: Modifier = Modifier,
) {
+ val topBarHazeStyle = HazeMaterials.thin()
Box(
modifier = modifier
.graphicsLayer { alpha = blurReveal.coerceIn(0f, 1f) }
- .hazeEffect(
- state = hazeState,
- style = HazeMaterials.thin(),
- ),
+ .hazeEffect(state = hazeState) {
+ blurEffect {
+ this.blurEnabled = blurEnabled
+ style = topBarHazeStyle
+ }
+ },
)
}
@@ -518,7 +521,7 @@ private fun ChatsSelectionTopBar(
)
}
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatsTab(
isVisible: Boolean = true,
@@ -573,7 +576,7 @@ fun ChatsTab(
val publicChatProfile = publicChatProfileFromFlow ?: publicChatProfileFromDisk
val searchBarHint = stringResource(Res.string.search_title)
val tabListState = rememberLazyListState()
- val topChromeHazeState = rememberHazeState(blurEnabled = isVisible)
+ val topChromeHazeState = rememberHazeState()
val statusMap by UserStatusStore.status.collectAsState()
var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) }
val statusSubscriptionScope = rememberCoroutineScope()
@@ -1032,6 +1035,7 @@ fun ChatsTab(
ChatsTopBarHazeBackdrop(
hazeState = topChromeHazeState,
blurReveal = topBarBlurReveal,
+ blurEnabled = isVisible,
modifier = Modifier.matchParentSize(),
)
Box(modifier = Modifier.fillMaxWidth()) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt
index da36953..751c63b 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt
@@ -67,11 +67,11 @@ import coil3.compose.AsyncImage
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.utils.currentDeviceInfo
-import dev.chrisbanes.haze.HazeProgressive
+import dev.chrisbanes.haze.blur.HazeProgressive
+import dev.chrisbanes.haze.blur.blurEffect
+import dev.chrisbanes.haze.blur.materials.HazeMaterials
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 io.ktor.client.network.sockets.ConnectTimeoutException
import io.ktor.client.network.sockets.SocketTimeoutException
@@ -363,13 +363,13 @@ private fun DeviceSessionDetailBottomSheet(
}
}
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class,
+@OptIn(ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class
)
@Composable
fun DevicesScreen(onBack: () -> Unit) {
val scope = rememberCoroutineScope()
- val hazeState = rememberHazeState(blurEnabled = true)
+ val hazeState = rememberHazeState()
val snackbarHostState = remember { SnackbarHostState() }
val initialCache = remember { Settings.readDeviceSessionsCache() }
@@ -430,6 +430,7 @@ fun DevicesScreen(onBack: () -> Unit) {
contentWindowInsets = WindowInsets.navigationBars,
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = {
+ val topBarHazeStyle = HazeMaterials.thin()
TopAppBar(
title = {},
navigationIcon = {
@@ -441,14 +442,14 @@ fun DevicesScreen(onBack: () -> Unit) {
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
),
- modifier = Modifier.hazeEffect(
- state = hazeState,
- style = HazeMaterials.thin()
- ) {
- progressive = HazeProgressive.verticalGradient(
- startIntensity = 1f,
- endIntensity = 0f,
- )
+ modifier = Modifier.hazeEffect(state = hazeState) {
+ blurEffect {
+ style = topBarHazeStyle
+ progressive = HazeProgressive.verticalGradient(
+ startIntensity = 1f,
+ endIntensity = 0f,
+ )
+ }
}
)
},
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt
index 994ad52..0560d06 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt
@@ -707,6 +707,7 @@ private fun formatLogFileSize(sizeBytes: Long): String {
if (sizeBytes < 1024 * 1024) {
return stringResource(Res.string.logs_file_size_kb, kb)
}
- val megabytes = "%.1f".format(sizeBytes / (1024f * 1024f))
+ val mb = sizeBytes / (1024.0 * 1024.0)
+ val megabytes = ((kotlin.math.round(mb * 10.0) / 10.0)).toString()
return stringResource(Res.string.logs_file_size_mb, megabytes)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt
index 59ae0bc..2c5115c 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt
@@ -44,6 +44,7 @@ import ru.fromchat.settings_notifications_permission_required
import ru.fromchat.settings_notifications_title
import ru.fromchat.settings_push_notifications
import ru.fromchat.settings_push_notifications_d
+import ru.fromchat.settings_push_notifications_unavailable
import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.Text
@@ -84,48 +85,58 @@ fun NotificationsScreen(onBack: () -> Unit) {
Category(
modifier = Modifier.padding(top = 16.dp)
) {
- SwitchListItem(
- headline = stringResource(Res.string.settings_push_notifications),
- supportingText = stringResource(Res.string.settings_push_notifications_d),
- leadingContent = {
- Icon(Icons.Filled.Notifications, null)
- },
- checked = notificationsEnabled,
- onCheckedChange = { enabled ->
- coroutineScope.launch {
- if (enabled) {
- if (!areAppNotificationsEnabled()) {
- if (!openAppNotificationSettings()) {
- snackbarHostState.showSnackbar(message = unexpectedErrorText)
- } else {
- snackbarHostState.showSnackbar(message = notificationsPermissionText)
+ if (arePushNotificationsSupported()) {
+ SwitchListItem(
+ headline = stringResource(Res.string.settings_push_notifications),
+ supportingText = stringResource(Res.string.settings_push_notifications_d),
+ leadingContent = {
+ Icon(Icons.Filled.Notifications, null)
+ },
+ checked = notificationsEnabled,
+ onCheckedChange = { enabled ->
+ coroutineScope.launch {
+ if (enabled) {
+ if (!areAppNotificationsEnabled()) {
+ if (!openAppNotificationSettings()) {
+ snackbarHostState.showSnackbar(message = unexpectedErrorText)
+ } else {
+ snackbarHostState.showSnackbar(message = notificationsPermissionText)
+ }
+ return@launch
}
- return@launch
- }
- val registered = ensureFcmTokenRegistered()
- if (registered) {
- notificationsEnabled = true
+ val registered = ensureFcmTokenRegistered()
+ if (registered) {
+ notificationsEnabled = true
+ } else {
+ snackbarHostState.showSnackbar(message = unexpectedErrorText)
+ }
} else {
- snackbarHostState.showSnackbar(message = unexpectedErrorText)
+ unregisterFcmTokenFromServer()
+ notificationsEnabled = false
}
- } else {
- unregisterFcmTokenFromServer()
- notificationsEnabled = false
}
- }
- },
- divider = true
- )
+ },
+ divider = true
+ )
- ListItem(
- headline = stringResource(Res.string.settings_notification_settings),
- supportingText = stringResource(Res.string.settings_notification_settings_d),
- leadingContent = {
- Icon(Icons.Filled.Settings, null)
- },
- onClick = { openAppNotificationSettings() }
- )
+ ListItem(
+ headline = stringResource(Res.string.settings_notification_settings),
+ supportingText = stringResource(Res.string.settings_notification_settings_d),
+ leadingContent = {
+ Icon(Icons.Filled.Settings, null)
+ },
+ onClick = { openAppNotificationSettings() }
+ )
+ } else {
+ ListItem(
+ headline = stringResource(Res.string.settings_push_notifications),
+ supportingText = stringResource(Res.string.settings_push_notifications_unavailable),
+ leadingContent = {
+ Icon(Icons.Filled.Notifications, null)
+ },
+ )
+ }
}
}
}
@@ -140,4 +151,7 @@ expect fun openAppNotificationSettings(): Boolean
/**
* Returns true when notifications are currently enabled for this app, including runtime permission.
*/
-expect fun areAppNotificationsEnabled(): Boolean
\ No newline at end of file
+expect fun areAppNotificationsEnabled(): Boolean
+
+/** False on platforms without push delivery (e.g. iOS without APNs). */
+expect fun arePushNotificationsSupported(): Boolean
\ No newline at end of file
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt
index a9bdd42..049b202 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt
@@ -67,11 +67,11 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.toDp
-import dev.chrisbanes.haze.HazeProgressive
+import dev.chrisbanes.haze.blur.HazeProgressive
+import dev.chrisbanes.haze.blur.blurEffect
+import dev.chrisbanes.haze.blur.materials.HazeMaterials
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -129,7 +129,6 @@ private object ServerConfigLazyListIndices {
ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class,
ExperimentalAnimationApi::class,
- ExperimentalHazeMaterialsApi::class,
)
@Composable
fun ServerConfigScreen() {
@@ -331,6 +330,7 @@ fun ServerConfigScreen() {
}
},
topBar = {
+ val topBarHazeStyle = HazeMaterials.thin()
TopAppBar(
title = {},
navigationIcon = {
@@ -345,11 +345,14 @@ fun ServerConfigScreen() {
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
),
- modifier = Modifier.hazeEffect(state = actionHazeState, style = HazeMaterials.thin()) {
- progressive = HazeProgressive.verticalGradient(
- startIntensity = 1f,
- endIntensity = 0f,
- )
+ modifier = Modifier.hazeEffect(state = actionHazeState) {
+ blurEffect {
+ style = topBarHazeStyle
+ progressive = HazeProgressive.verticalGradient(
+ startIntensity = 1f,
+ endIntensity = 0f,
+ )
+ }
}
)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt
index f4dac89..a799067 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt
@@ -47,11 +47,11 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
-import dev.chrisbanes.haze.HazeProgressive
+import dev.chrisbanes.haze.blur.HazeProgressive
+import dev.chrisbanes.haze.blur.blurEffect
+import dev.chrisbanes.haze.blur.materials.HazeMaterials
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 io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
@@ -110,7 +110,6 @@ private object EditProfileLazyListIndices {
ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class,
ExperimentalFoundationApi::class,
- ExperimentalHazeMaterialsApi::class,
)
@Composable
fun EditProfileScreen(
@@ -340,6 +339,7 @@ fun EditProfileScreen(
}
},
topBar = {
+ val topBarHazeStyle = HazeMaterials.thin()
TopAppBar(
title = { Text(stringResource(Res.string.profile_edit_title)) },
navigationIcon = {
@@ -367,11 +367,14 @@ fun EditProfileScreen(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
- modifier = Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
- progressive = HazeProgressive.verticalGradient(
- startIntensity = 1f,
- endIntensity = 0f,
- )
+ modifier = Modifier.hazeEffect(state = hazeState) {
+ blurEffect {
+ style = topBarHazeStyle
+ progressive = HazeProgressive.verticalGradient(
+ startIntensity = 1f,
+ endIntensity = 0f,
+ )
+ }
},
)
},
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
index 8aa5a2d..1263ebb 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
@@ -37,11 +37,13 @@ import androidx.compose.ui.unit.Dp
import androidx.navigation.NavController
import com.pr0gramm3r101.components.ListItemPosition
import com.pr0gramm3r101.utils.SupportClipboardManager
-import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.blur.HazeProgressive
+import dev.chrisbanes.haze.blur.blurEffect
+import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
-import dev.chrisbanes.haze.materials.HazeMaterials
+import dev.chrisbanes.haze.hazeSource
+import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.CoroutineScope
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
@@ -112,8 +114,6 @@ import com.pr0gramm3r101.components.ContextMenuPressable
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.components.listItemPositionInGroup
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
-import dev.chrisbanes.haze.hazeSource
-import dev.chrisbanes.haze.rememberHazeState
import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
@@ -2028,7 +2028,6 @@ private fun ExpressiveProfileActionButton(
}
}
-@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun ProfileFloatingBackBar(
visible: Boolean,
@@ -2039,16 +2038,20 @@ private fun ProfileFloatingBackBar(
) {
if (!visible) return
+ val backBarHazeStyle = HazeMaterials.thin()
Box(modifier = modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(blurHeight)
- .hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
- progressive = HazeProgressive.verticalGradient(
- startIntensity = 1f,
- endIntensity = 0f,
- )
+ .hazeEffect(state = hazeState) {
+ blurEffect {
+ style = backBarHazeStyle
+ progressive = HazeProgressive.verticalGradient(
+ startIntensity = 1f,
+ endIntensity = 0f,
+ )
+ }
},
)
IconButton(
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/IosApplicationBootstrap.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/IosApplicationBootstrap.kt
index fa136b2..4dad94f 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/IosApplicationBootstrap.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/IosApplicationBootstrap.kt
@@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
+import ru.fromchat.api.local.send.installOutboxForegroundDrain
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
object IosApplicationBootstrap {
@@ -16,6 +17,7 @@ object IosApplicationBootstrap {
fun launchOnApplicationStart() {
if (started) return
started = true
+ installOutboxForegroundDrain()
scope.launch {
runCatching { ApiClient.loadPersistedData() }
runCatching { AttachmentTransferBootstrap.runColdStart() }
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/KeepWebSocketAlive.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/KeepWebSocketAlive.ios.kt
new file mode 100644
index 0000000..1709232
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/KeepWebSocketAlive.ios.kt
@@ -0,0 +1,3 @@
+package ru.fromchat
+
+actual fun keepWebSocketAliveInBackground(): Boolean = false
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.ios.kt
index 97c6f85..5f8e89c 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.ios.kt
@@ -1,5 +1,5 @@
package ru.fromchat.api
actual suspend fun syncPushTokenAfterStartup() {
- // айфон г@вно поэтому этого не будет потому что не хочу платить кучу денег за App Store
+ // iOS: no APNs / FCM push token sync in this build.
}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.ios.kt
index ebf4d74..3c01797 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.ios.kt
@@ -11,7 +11,7 @@ import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
import platform.Foundation.create
import platform.Foundation.writeToFile
-import ru.fromchat.api.db.MessageDatabaseProvider
+import ru.fromchat.api.local.db.store.MessageDatabaseProvider
private const val GENERATION_FILE = ".generation"
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/LocalDecodedImageCache.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.ios.kt
similarity index 97%
rename from app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/LocalDecodedImageCache.ios.kt
rename to app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.ios.kt
index ec86fa9..bb72fd4 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/LocalDecodedImageCache.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.ios.kt
@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
-package ru.fromchat.api.local.cache
+package ru.fromchat.api.local.download
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
@@ -23,8 +23,6 @@ import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext
import platform.UIKit.UIImage
import platform.UIKit.UIImageOrientation
import platform.UIKit.UIImagePNGRepresentation
-import ru.fromchat.api.local.download.decodeImageBytes
-
actual object PlatformDecodedBitmapCache {
private val cache = mutableMapOf()
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.ios.kt
index 2e062fe..74f610f 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.ios.kt
@@ -1,5 +1,36 @@
+@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
+
package ru.fromchat.api.local.download
-internal actual fun readLocalImageDimensions(absolutePath: String): Pair? = null
+import kotlinx.cinterop.BetaInteropApi
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.useContents
+import kotlinx.cinterop.usePinned
+import platform.Foundation.NSData
+import platform.Foundation.create
+import platform.UIKit.UIImage
-internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair? = null
+internal actual fun readLocalImageDimensions(absolutePath: String): Pair? =
+ runCatching {
+ val image = UIImage.imageWithContentsOfFile(absolutePath) ?: return null
+ pixelSize(image)
+ }.getOrNull()
+
+internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair? {
+ if (data.isEmpty()) return null
+ return runCatching {
+ val nsData = data.usePinned { pinned ->
+ NSData.create(bytes = pinned.addressOf(0), length = data.size.toULong())
+ } ?: return null
+ val image = UIImage.imageWithData(nsData) ?: return null
+ pixelSize(image)
+ }.getOrNull()
+}
+
+private fun pixelSize(image: UIImage): Pair? {
+ val w = (image.size.useContents { width } * image.scale).toInt()
+ val h = (image.size.useContents { height } * image.scale).toInt()
+ if (w <= 0 || h <= 0) return null
+ return w to h
+}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.ios.kt
index 1119661..495d451 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.ios.kt
@@ -2,8 +2,29 @@ package ru.fromchat.api.local.send
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
+import platform.Foundation.NSNotificationCenter
+import platform.UIKit.UIApplicationDidBecomeActiveNotification
+import ru.fromchat.api.local.cache.CacheContext
-/** iOS: drain outbox on enqueue and when the app returns to foreground (parity with WorkManager). */
+private var foregroundDrainInstalled = false
+
+/** Install once: drain outbox when the app becomes active (parity with WorkManager). */
+fun installOutboxForegroundDrain() {
+ if (foregroundDrainInstalled) return
+ foregroundDrainInstalled = true
+ NSNotificationCenter.defaultCenter.addObserverForName(
+ name = UIApplicationDidBecomeActiveNotification,
+ `object` = null,
+ queue = null,
+ ) { _ ->
+ val instanceId = CacheContext.activeInstanceId.value.trim()
+ if (instanceId.isNotEmpty()) {
+ scheduleOutboxProcessing(instanceId)
+ }
+ }
+}
+
+/** iOS: drain outbox on enqueue and when the app returns to foreground. */
actual fun scheduleOutboxProcessing(instanceId: String) {
if (instanceId.trim().isEmpty()) return
MainScope().launch {
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.ios.kt
index 35f06f4..2010f63 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.ios.kt
@@ -1,8 +1,12 @@
package ru.fromchat.api.local.workers
+import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.SharedFlow
-import ru.fromchat.api.local.send.scheduleOutboxProcessing
+import kotlinx.coroutines.launch
import ru.fromchat.api.local.cache.CacheContext
+import ru.fromchat.api.local.db.store.MessageDatabaseProvider
+import ru.fromchat.api.local.send.OutgoingMessageCoordinator
+import ru.fromchat.api.local.send.scheduleOutboxProcessing
actual object AttachmentUploadQueue {
actual val progressFlow: SharedFlow = AttachmentUploadNotifier.progressFlow
@@ -12,6 +16,14 @@ actual object AttachmentUploadQueue {
}
actual fun cancel(jobId: String) {
- // Outbox + artifact cleanup handled when user deletes pending message from UI.
+ val instanceId = CacheContext.activeInstanceId.value.trim()
+ if (instanceId.isEmpty()) return
+ MainScope().launch {
+ val row = MessageDatabaseProvider.database.messageDatabaseQueries
+ .selectOutboxItem(instanceId, jobId.trim())
+ .executeAsOneOrNull()
+ val conversationId = row?.conversationId ?: return@launch
+ OutgoingMessageCoordinator.cancelOutboundMessage(jobId, conversationId)
+ }
}
}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/logging/GzipCompress.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/logging/GzipCompress.ios.kt
index 0ac6c59..91c58ad 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/logging/GzipCompress.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/logging/GzipCompress.ios.kt
@@ -2,21 +2,28 @@ package ru.fromchat.logging
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.UByteVar
+import kotlinx.cinterop.addressOf
import kotlinx.cinterop.alloc
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.convert
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.reinterpret
-import kotlinx.cinterop.sizeOf
import kotlinx.cinterop.toCValues
+import kotlinx.cinterop.usePinned
+import kotlinx.cinterop.value
+import platform.posix.memcpy
import platform.zlib.Z_DEFAULT_COMPRESSION
import platform.zlib.compress2
+import platform.zlib.uLongVar
@OptIn(ExperimentalForeignApi::class)
internal actual fun gzipCompress(input: ByteArray): ByteArray {
if (input.isEmpty()) {
- return byteArrayOf(0x1f, 0x8b.toByte(), 0x08, 0x00, 0, 0, 0, 0, 0, 0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
+ return byteArrayOf(
+ 0x1f, 0x8b.toByte(), 0x08, 0x00, 0, 0, 0, 0, 0, 0x03,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ )
}
val header = byteArrayOf(
@@ -50,34 +57,42 @@ internal actual fun gzipCompress(input: ByteArray): ByteArray {
}
@OptIn(ExperimentalForeignApi::class)
-private fun deflateRaw(input: ByteArray): ByteArray = memScoped {
- if (input.isEmpty()) return@memScoped ByteArray(0)
+private fun deflateRaw(input: ByteArray): ByteArray {
+ if (input.isEmpty()) return ByteArray(0)
- var capacity = (input.size + (input.size / 10) + 12).coerceAtLeast(64)
- while (true) {
- val output = allocArray(capacity)
- val source = input.toUByteArray().toCValues()
- val sourceLength = input.size.convert()
- val destLength = alloc()
- destLength.value = capacity.convert()
+ return memScoped {
+ var capacity = (input.size + (input.size / 10) + 12).coerceAtLeast(64)
+ while (true) {
+ val output = allocArray(capacity)
+ val source = input.toUByteArray().toCValues()
+ val sourceLength = input.size.convert()
+ val destLength = alloc()
+ destLength.value = capacity.convert()
- val status = compress2(
- output,
- destLength.ptr,
- source.ptr.reinterpret(),
- sourceLength,
- Z_DEFAULT_COMPRESSION,
- )
+ val status = compress2(
+ output,
+ destLength.ptr,
+ source.ptr.reinterpret(),
+ sourceLength,
+ Z_DEFAULT_COMPRESSION,
+ )
- if (status == 0) {
- val size = destLength.value.toInt()
- return@memScoped ByteArray(size) { index -> output[index].toByte() }
- }
-
- capacity *= 2
- if (capacity > input.size * 20) {
- return@memScoped input
+ if (status == 0) {
+ val size = destLength.value.toInt()
+ val result = ByteArray(size)
+ result.usePinned { pinned ->
+ memcpy(pinned.addressOf(0), output, size.convert())
+ }
+ return@memScoped result
+ }
+
+ capacity *= 2
+ if (capacity > input.size * 20) {
+ return@memScoped input
+ }
}
+ @Suppress("UNREACHABLE_CODE")
+ ByteArray(0)
}
}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/logging/LogFileOps.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/logging/LogFileOps.ios.kt
index 08980c2..1934d0e 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/logging/LogFileOps.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/logging/LogFileOps.ios.kt
@@ -6,28 +6,30 @@ import kotlinx.cinterop.addressOf
import kotlinx.cinterop.alloc
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.convert
+import kotlinx.cinterop.get
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.toCValues
import kotlinx.cinterop.usePinned
+import kotlinx.cinterop.value
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
-import platform.Foundation.NSString
-import platform.Foundation.NSUTF8StringEncoding
import platform.Foundation.create
import platform.Foundation.dataWithContentsOfFile
import platform.Foundation.writeToFile
import platform.posix.memcpy
+import platform.zlib.uLongVar
+import platform.zlib.uncompress
@OptIn(ExperimentalForeignApi::class)
internal actual object LogFileOps {
actual fun readText(path: String): String {
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return ""
- return NSString.stringWithContentsOfFile(path, encoding = NSUTF8StringEncoding, error = null) as? String
- ?: ""
+ val raw = NSData.dataWithContentsOfFile(path) ?: return ""
+ return raw.toByteArray().decodeToString()
}
actual fun readBytes(path: String): ByteArray {
@@ -36,53 +38,60 @@ internal actual object LogFileOps {
return raw.toByteArray()
}
- actual suspend fun gzipFile(sourcePath: String, destinationPath: String) = withContext(Dispatchers.Default) {
- if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@withContext
- val raw = NSData.dataWithContentsOfFile(sourcePath) ?: return@withContext
- val bytes = raw.toByteArray()
- val gzipped = gzipCompress(bytes)
- val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
- NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
- NSData.create(bytes = gzipped, length = gzipped.size.toULong())
- .writeToFile(destinationPath, true)
- NSFileManager.defaultManager.removeItemAtPath(sourcePath, null)
+ actual suspend fun gzipFile(sourcePath: String, destinationPath: String) {
+ withContext(Dispatchers.Default) {
+ if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@withContext
+ val raw = NSData.dataWithContentsOfFile(sourcePath) ?: return@withContext
+ val gzipped = gzipCompress(raw.toByteArray())
+ val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
+ NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
+ writeBytes(destinationPath, gzipped)
+ NSFileManager.defaultManager.removeItemAtPath(sourcePath, null)
+ }
}
actual suspend fun readGzipText(path: String, onProgress: (Float) -> Unit): String =
withContext(Dispatchers.Default) {
- val bytes = gunzipToByteArray(path, onProgress)
- bytes.decodeToString()
+ gunzipToByteArray(path, onProgress).decodeToString()
}
actual suspend fun gunzipToFile(
sourcePath: String,
destinationPath: String,
onProgress: (Float) -> Unit,
- ) = withContext(Dispatchers.Default) {
- val bytes = gunzipToByteArray(sourcePath, onProgress)
- val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
- NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
- NSData.create(bytes = bytes, length = bytes.size.toULong())
- .writeToFile(destinationPath, true)
+ ) {
+ withContext(Dispatchers.Default) {
+ val bytes = gunzipToByteArray(sourcePath, onProgress)
+ val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
+ NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
+ writeBytes(destinationPath, bytes)
+ }
}
actual suspend fun zipFiles(
entries: List>,
destinationPath: String,
- ) = withContext(Dispatchers.Default) {
- if (entries.isEmpty()) return@withContext
- val zipEntries = entries.mapNotNull { (entryName, sourcePath) ->
- if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@mapNotNull null
- ZipFileEntry(entryName, readBytes(sourcePath))
+ ) {
+ withContext(Dispatchers.Default) {
+ if (entries.isEmpty()) return@withContext
+ val zipEntries = entries.mapNotNull { (entryName, sourcePath) ->
+ if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@mapNotNull null
+ ZipFileEntry(entryName, readBytes(sourcePath))
+ }
+ val bytes = buildStoreZipArchive(zipEntries)
+ val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
+ NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
+ writeBytes(destinationPath, bytes)
+ }
+ }
+
+ private fun writeBytes(path: String, bytes: ByteArray) {
+ bytes.usePinned { pinned ->
+ NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
+ ?.writeToFile(path, true)
}
- val bytes = buildStoreZipArchive(zipEntries)
- val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
- NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
- NSData.create(bytes = bytes, length = bytes.size.toULong())
- .writeToFile(destinationPath, true)
}
- @OptIn(ExperimentalForeignApi::class)
private fun gunzipToByteArray(path: String, onProgress: (Float) -> Unit): ByteArray {
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) {
onProgress(1f)
@@ -103,17 +112,16 @@ internal actual object LogFileOps {
return inflated
}
- @OptIn(ExperimentalForeignApi::class)
private fun inflateGzipPayload(deflated: ByteArray): ByteArray = memScoped {
if (deflated.isEmpty()) return@memScoped ByteArray(0)
var capacity = (deflated.size * 4).coerceAtLeast(256)
while (capacity <= deflated.size * 32) {
val output = allocArray(capacity)
- val destLength = alloc()
- destLength.value = capacity.convert()
+ val destLength = alloc()
+ destLength.value = capacity.convert()
val source = deflated.toUByteArray().toCValues()
- val status = platform.zlib.uncompress(
+ val status = uncompress(
output,
destLength.ptr,
source.ptr.reinterpret(),
@@ -121,14 +129,17 @@ internal actual object LogFileOps {
)
if (status == 0) {
val size = destLength.value.toInt()
- return@memScoped ByteArray(size) { index -> output[index].toByte() }
+ val result = ByteArray(size)
+ result.usePinned { pinned ->
+ memcpy(pinned.addressOf(0), output, size.convert())
+ }
+ return@memScoped result
}
capacity *= 2
}
ByteArray(0)
}
- @OptIn(ExperimentalForeignApi::class)
private fun NSData.toByteArray(): ByteArray {
val length = this.length.toInt()
if (length == 0) return ByteArray(0)
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.ios.kt
index ba42a3f..bb97d58 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.ios.kt
@@ -1,10 +1,67 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
package ru.fromchat.ui.auth.captcha
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.viewinterop.UIKitView
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.ObjCSignatureOverride
+import platform.CoreGraphics.CGRectMake
+import platform.Foundation.NSError
+import platform.Foundation.NSURL
+import platform.Foundation.NSURLRequest
+import platform.UIKit.UIColor
+import platform.WebKit.WKNavigation
+import platform.WebKit.WKNavigationAction
+import platform.WebKit.WKNavigationActionPolicy
+import platform.WebKit.WKNavigationDelegateProtocol
+import platform.WebKit.WKScriptMessage
+import platform.WebKit.WKScriptMessageHandlerProtocol
+import platform.WebKit.WKUserContentController
+import platform.WebKit.WKUserScript
+import platform.WebKit.WKUserScriptInjectionTime
+import platform.WebKit.WKWebView
+import platform.WebKit.WKWebViewConfiguration
+import platform.darwin.NSObject
import ru.fromchat.Logger
+private const val SMARTCAPTCHA_WEBVIEW_BASE = "https://smartcaptcha.cloud.yandex.ru/webview"
+private const val NATIVE_CLIENT_HANDLER = "NativeClient"
+
+/**
+ * Injected at document start so [window.NativeClient] matches Android's JavascriptInterface
+ * before SmartCaptcha scripts run (including iframes).
+ */
+private const val NATIVE_CLIENT_BRIDGE_JS = """
+(function() {
+ if (window.NativeClient && window.NativeClient.__fromchat) return;
+ function post(payload) {
+ try {
+ window.webkit.messageHandlers.NativeClient.postMessage(payload);
+ } catch (e) {}
+ }
+ window.NativeClient = {
+ __fromchat: true,
+ onGetToken: function(token) {
+ post({ method: 'onGetToken', token: String(token == null ? '' : token) });
+ },
+ onChallengeVisible: function() {
+ post({ method: 'onChallengeVisible' });
+ },
+ onChallengeHidden: function() {
+ post({ method: 'onChallengeHidden' });
+ }
+ };
+})();
+"""
+
@Composable
actual fun SmartCaptchaWebView(
sitekey: String,
@@ -16,12 +73,186 @@ actual fun SmartCaptchaWebView(
onChallengeHidden: () -> Unit,
onError: (String) -> Unit,
) {
- LaunchedEffect(sitekey) {
- Logger.w(
- SmartCaptchaLog.TAG,
- "iOS stub: captcha unavailable sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
- "languageTag=$languageTag",
- )
- onError("Captcha is not available on this platform yet.")
+ val onTokenState = rememberUpdatedState(onToken)
+ val onReadyState = rememberUpdatedState(onReady)
+ val onChallengeVisibleState = rememberUpdatedState(onChallengeVisible)
+ val onChallengeHiddenState = rememberUpdatedState(onChallengeHidden)
+ val onErrorState = rememberUpdatedState(onError)
+ val background = MaterialTheme.colorScheme.surfaceContainer
+ val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
+ val captchaUrl = remember(sitekey, lang) {
+ "$SMARTCAPTCHA_WEBVIEW_BASE?sitekey=${sitekey.trim()}&hl=$lang"
}
+ val instanceId = remember { (100000..999999).random().toString(16) }
+ val heldWebView = remember { arrayOfNulls(1) }
+
+ val messageHandler = remember {
+ object : NSObject(), WKScriptMessageHandlerProtocol {
+ override fun userContentController(
+ userContentController: WKUserContentController,
+ didReceiveScriptMessage: WKScriptMessage,
+ ) {
+ if (didReceiveScriptMessage.name != NATIVE_CLIENT_HANDLER) return
+ val body = didReceiveScriptMessage.body
+ val map = body as? Map<*, *>
+ val method = map?.get("method") as? String ?: return
+ when (method) {
+ "onGetToken" -> {
+ val cleaned = (map["token"] as? String).orEmpty().trim()
+ Logger.i(
+ SmartCaptchaLog.TAG,
+ "JS onGetToken id=$instanceId ${SmartCaptchaLog.redactToken(cleaned)}",
+ )
+ if (cleaned.isNotEmpty()) {
+ onTokenState.value(cleaned)
+ } else {
+ Logger.w(SmartCaptchaLog.TAG, "JS onGetToken empty id=$instanceId")
+ onErrorState.value("")
+ }
+ }
+ "onChallengeVisible" -> {
+ Logger.i(SmartCaptchaLog.TAG, "JS onChallengeVisible id=$instanceId")
+ onChallengeVisibleState.value()
+ }
+ "onChallengeHidden" -> {
+ Logger.i(SmartCaptchaLog.TAG, "JS onChallengeHidden id=$instanceId")
+ onChallengeHiddenState.value()
+ }
+ }
+ }
+ }
+ }
+
+ val navDelegate = remember {
+ object : NSObject(), WKNavigationDelegateProtocol {
+ override fun webView(
+ webView: WKWebView,
+ decidePolicyForNavigationAction: WKNavigationAction,
+ decisionHandler: (WKNavigationActionPolicy) -> Unit,
+ ) {
+ val url = decidePolicyForNavigationAction.request.URL?.absoluteString
+ val host = decidePolicyForNavigationAction.request.URL?.host?.lowercase().orEmpty()
+ val block = host.isNotEmpty() &&
+ !host.endsWith("yandex.ru") &&
+ !host.endsWith("yandex.com") &&
+ !host.endsWith("yandex.net")
+ Logger.d(
+ SmartCaptchaLog.TAG,
+ "decidePolicy id=$instanceId block=$block host=$host " +
+ "url=${SmartCaptchaLog.shortUrl(url)}",
+ )
+ decisionHandler(
+ if (block) {
+ WKNavigationActionPolicy.WKNavigationActionPolicyCancel
+ } else {
+ WKNavigationActionPolicy.WKNavigationActionPolicyAllow
+ },
+ )
+ }
+
+ @ObjCSignatureOverride
+ override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) {
+ Logger.i(
+ SmartCaptchaLog.TAG,
+ "didFinishNavigation id=$instanceId " +
+ "url=${SmartCaptchaLog.shortUrl(webView.URL?.absoluteString)}",
+ )
+ onReadyState.value()
+ }
+
+ @ObjCSignatureOverride
+ override fun webView(
+ webView: WKWebView,
+ didFailNavigation: WKNavigation?,
+ withError: NSError,
+ ) {
+ Logger.w(
+ SmartCaptchaLog.TAG,
+ "didFailNavigation id=$instanceId desc=${withError.localizedDescription}",
+ )
+ onErrorState.value(withError.localizedDescription)
+ }
+
+ @ObjCSignatureOverride
+ override fun webView(
+ webView: WKWebView,
+ didFailProvisionalNavigation: WKNavigation?,
+ withError: NSError,
+ ) {
+ Logger.w(
+ SmartCaptchaLog.TAG,
+ "didFailProvisionalNavigation id=$instanceId " +
+ "desc=${withError.localizedDescription}",
+ )
+ onErrorState.value(withError.localizedDescription)
+ }
+ }
+ }
+
+ DisposableEffect(instanceId) {
+ Logger.i(
+ SmartCaptchaLog.TAG,
+ "WebView compose enter id=$instanceId sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
+ "lang=$lang languageTag=$languageTag url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
+ )
+ onDispose {
+ Logger.i(SmartCaptchaLog.TAG, "WebView compose dispose id=$instanceId")
+ heldWebView[0]?.let { wv ->
+ wv.configuration.userContentController
+ .removeScriptMessageHandlerForName(NATIVE_CLIENT_HANDLER)
+ wv.setNavigationDelegate(null)
+ wv.stopLoading()
+ }
+ heldWebView[0] = null
+ }
+ }
+
+ UIKitView(
+ factory = {
+ Logger.i(SmartCaptchaLog.TAG, "UIKitView.factory id=$instanceId")
+ val contentController = WKUserContentController().apply {
+ addScriptMessageHandler(messageHandler, name = NATIVE_CLIENT_HANDLER)
+ addUserScript(
+ WKUserScript(
+ source = NATIVE_CLIENT_BRIDGE_JS,
+ injectionTime = WKUserScriptInjectionTime.WKUserScriptInjectionTimeAtDocumentStart,
+ forMainFrameOnly = false,
+ ),
+ )
+ }
+ val config = WKWebViewConfiguration().apply {
+ setUserContentController(contentController)
+ allowsInlineMediaPlayback = true
+ }
+ WKWebView(
+ frame = CGRectMake(0.0, 0.0, 0.0, 0.0),
+ configuration = config,
+ ).apply {
+ opaque = true
+ backgroundColor = background.toUIColor()
+ scrollView.backgroundColor = background.toUIColor()
+ setNavigationDelegate(navDelegate)
+ Logger.i(
+ SmartCaptchaLog.TAG,
+ "loadUrl id=$instanceId url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
+ )
+ loadRequest(NSURLRequest(uRL = NSURL(string = captchaUrl)))
+ heldWebView[0] = this
+ }
+ },
+ modifier = modifier.fillMaxSize(),
+ update = { wv ->
+ wv.backgroundColor = background.toUIColor()
+ wv.scrollView.backgroundColor = background.toUIColor()
+ heldWebView[0] = wv
+ },
+ )
}
+
+private fun Color.toUIColor(): UIColor =
+ UIColor(
+ red = red.toDouble(),
+ green = green.toDouble(),
+ blue = blue.toDouble(),
+ alpha = alpha.toDouble(),
+ )
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.ios.kt
index d0b9f96..0f2ff56 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.ios.kt
@@ -1,9 +1,108 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
package ru.fromchat.ui.auth.yandex
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.LoadingIndicator
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.viewinterop.UIKitView
+import kotlinx.coroutines.delay
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.ObjCSignatureOverride
+import platform.CoreGraphics.CGRectMake
+import platform.Foundation.NSHTTPCookie
+import platform.Foundation.NSHTTPCookieStorage
+import platform.Foundation.NSURL
+import platform.Foundation.NSURLRequest
+import platform.UIKit.UIApplication
+import platform.UIKit.UIColor
+import platform.WebKit.WKNavigation
+import platform.WebKit.WKNavigationAction
+import platform.WebKit.WKNavigationActionPolicy
+import platform.WebKit.WKNavigationDelegateProtocol
+import platform.WebKit.WKUserScript
+import platform.WebKit.WKUserScriptInjectionTime
+import platform.WebKit.WKWebView
+import platform.WebKit.WKWebViewConfiguration
+import platform.darwin.NSObject
+import ru.fromchat.Logger
+import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
+import ru.fromchat.auth.yandex.extractOAuthCode
+private const val LOG_TAG = "YandexOAuthWV"
+private const val LOADING_FADE_MS = 250
+private const val LOADING_HIDE_DELAY_MS = 500L
+
+private fun shortUrl(url: String?): String {
+ if (url.isNullOrBlank()) return "null"
+ return if (url.length <= 120) url else url.take(117) + "..."
+}
+
+private const val FORCE_COLOR_SCHEME_JS = """
+(function(scheme) {
+ try {
+ var meta = document.querySelector('meta[name="color-scheme"]');
+ if (!meta) {
+ meta = document.createElement('meta');
+ meta.name = 'color-scheme';
+ (document.head || document.documentElement).appendChild(meta);
+ }
+ meta.content = scheme;
+ document.documentElement.style.colorScheme = scheme;
+ document.documentElement.setAttribute('data-theme', scheme);
+ document.documentElement.classList.toggle('theme_dark', scheme === 'dark');
+ document.documentElement.classList.toggle('Theme_color_dark', scheme === 'dark');
+ document.documentElement.classList.toggle('theme_light', scheme === 'light');
+ try { localStorage.setItem('color-scheme', scheme); } catch (e) {}
+ try { localStorage.setItem('theme', scheme); } catch (e) {}
+ } catch (e) {}
+})
+"""
+
+private const val DISABLE_USER_SELECT_JS = """
+(function() {
+ try {
+ var id = 'fromchat-no-select';
+ if (document.getElementById(id)) return;
+ var s = document.createElement('style');
+ s.id = id;
+ s.textContent = [
+ '*,*::before,*::after{',
+ '-webkit-user-select:none!important;',
+ 'user-select:none!important;',
+ '-webkit-touch-callout:none!important;',
+ '}',
+ 'input,textarea,select,[contenteditable],[contenteditable="true"],',
+ 'input *,textarea *,[contenteditable] *,[contenteditable="true"] *{',
+ '-webkit-user-select:text!important;',
+ 'user-select:text!important;',
+ '-webkit-touch-callout:default!important;',
+ '}'
+ ].join('');
+ (document.head || document.documentElement).appendChild(s);
+ } catch (e) {}
+})();
+"""
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
actual fun YandexOAuthWebView(
authorizeUrl: String,
@@ -17,7 +116,287 @@ actual fun YandexOAuthWebView(
onError: (String) -> Unit,
onCancel: () -> Unit,
) {
- LaunchedEffect(authorizeUrl) {
- onError("Yandex sign-in is not available on this platform yet.")
+ var canGoBack by remember { mutableStateOf(false) }
+ var pageLoading by remember { mutableStateOf(true) }
+ var showLoadingOverlay by remember { mutableStateOf(true) }
+ val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
+ val scheme = if (darkTheme) "dark" else "light"
+ val onHistoryBackAvailabilityChangedState = rememberUpdatedState(onHistoryBackAvailabilityChanged)
+ val onPageBackgroundColorState = rememberUpdatedState(onPageBackgroundColor)
+ val onCodeState = rememberUpdatedState(onCode)
+ val onErrorState = rememberUpdatedState(onError)
+ val instanceId = remember { (100000..999999).random().toString(16) }
+ val heldWebView = remember { arrayOfNulls(1) }
+
+ fun updateCanGoBack(value: Boolean) {
+ if (canGoBack != value) {
+ Logger.d(LOG_TAG, "canGoBack $canGoBack → $value id=$instanceId")
+ canGoBack = value
+ onHistoryBackAvailabilityChangedState.value(value)
+ }
+ }
+
+ DisposableEffect(instanceId) {
+ Logger.i(
+ LOG_TAG,
+ "compose enter id=$instanceId darkTheme=$darkTheme lang=$lang " +
+ "authorizeUrl=${shortUrl(authorizeUrl)}",
+ )
+ onPageBackgroundColorState.value(fallbackColor)
+ onDispose {
+ Logger.i(LOG_TAG, "compose dispose id=$instanceId")
+ heldWebView[0]?.let { wv ->
+ wv.setNavigationDelegate(null)
+ wv.stopLoading()
+ }
+ heldWebView[0] = null
+ }
+ }
+
+ LaunchedEffect(pageLoading) {
+ if (pageLoading) {
+ showLoadingOverlay = true
+ } else {
+ delay(LOADING_HIDE_DELAY_MS)
+ showLoadingOverlay = false
+ }
+ }
+
+ val navDelegate = remember(authorizeUrl, lang, darkTheme) {
+ object : NSObject(), WKNavigationDelegateProtocol {
+ private fun handleSpecialUrl(url: String?): Boolean {
+ if (url.isNullOrBlank()) return false
+ if (url.startsWith("fromchat://", ignoreCase = true)) {
+ Logger.i(LOG_TAG, "intercept redirect url=${shortUrl(url)} id=$instanceId")
+ val code = extractOAuthCode(url)
+ if (code != null) {
+ onCodeState.value(code)
+ } else {
+ onErrorState.value("")
+ }
+ return true
+ }
+ if (!isYandexAuthNavigationIos(url)) {
+ Logger.d(LOG_TAG, "external nav url=${shortUrl(url)} id=$instanceId")
+ openExternalUrl(url)
+ return true
+ }
+ return false
+ }
+
+ private fun applyPageChrome(webView: WKWebView) {
+ webView.evaluateJavaScript("$FORCE_COLOR_SCHEME_JS('$scheme');", completionHandler = null)
+ webView.evaluateJavaScript(DISABLE_USER_SELECT_JS, completionHandler = null)
+ }
+
+ override fun webView(
+ webView: WKWebView,
+ decidePolicyForNavigationAction: WKNavigationAction,
+ decisionHandler: (WKNavigationActionPolicy) -> Unit,
+ ) {
+ val url = decidePolicyForNavigationAction.request.URL?.absoluteString
+ if (handleSpecialUrl(url)) {
+ decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyCancel)
+ } else {
+ decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyAllow)
+ }
+ }
+
+ @ObjCSignatureOverride
+ override fun webView(webView: WKWebView, didCommitNavigation: WKNavigation?) {
+ val url = webView.URL?.absoluteString
+ Logger.i(LOG_TAG, "didCommitNavigation id=$instanceId url=${shortUrl(url)}")
+ if (url != null && url.startsWith(YANDEX_OAUTH_REDIRECT_URI, ignoreCase = true)) {
+ handleSpecialUrl(url)
+ }
+ pageLoading = true
+ applyPageChrome(webView)
+ updateCanGoBack(webView.canGoBack)
+ }
+
+ @ObjCSignatureOverride
+ override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) {
+ Logger.i(
+ LOG_TAG,
+ "didFinishNavigation id=$instanceId url=${shortUrl(webView.URL?.absoluteString)} " +
+ "canGoBack=${webView.canGoBack}",
+ )
+ pageLoading = false
+ applyPageChrome(webView)
+ updateCanGoBack(webView.canGoBack)
+ }
+
+ @ObjCSignatureOverride
+ override fun webView(
+ webView: WKWebView,
+ didFailNavigation: WKNavigation?,
+ withError: platform.Foundation.NSError,
+ ) {
+ Logger.w(
+ LOG_TAG,
+ "didFailNavigation id=$instanceId desc=${withError.localizedDescription}",
+ )
+ pageLoading = false
+ updateCanGoBack(webView.canGoBack)
+ }
+
+ @ObjCSignatureOverride
+ override fun webView(
+ webView: WKWebView,
+ didFailProvisionalNavigation: WKNavigation?,
+ withError: platform.Foundation.NSError,
+ ) {
+ Logger.w(
+ LOG_TAG,
+ "didFailProvisionalNavigation id=$instanceId " +
+ "desc=${withError.localizedDescription}",
+ )
+ pageLoading = false
+ updateCanGoBack(webView.canGoBack)
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(fallbackColor),
+ ) {
+ UIKitView(
+ factory = {
+ Logger.i(
+ LOG_TAG,
+ "UIKitView.factory START id=$instanceId darkTheme=$darkTheme " +
+ "clearCookies=$clearCookies",
+ )
+ if (clearCookies) {
+ clearYandexWebViewCookiesIos()
+ }
+ seedYandexThemeCookiesIos(darkTheme)
+ val config = WKWebViewConfiguration().apply {
+ allowsInlineMediaPlayback = true
+ userContentController.addUserScript(
+ WKUserScript(
+ source = "$FORCE_COLOR_SCHEME_JS('$scheme');$DISABLE_USER_SELECT_JS",
+ injectionTime =
+ WKUserScriptInjectionTime.WKUserScriptInjectionTimeAtDocumentStart,
+ forMainFrameOnly = true,
+ ),
+ )
+ }
+ WKWebView(
+ frame = CGRectMake(0.0, 0.0, 0.0, 0.0),
+ configuration = config,
+ ).apply {
+ opaque = true
+ backgroundColor = fallbackColor.toUIColor()
+ scrollView.backgroundColor = fallbackColor.toUIColor()
+ setNavigationDelegate(navDelegate)
+ Logger.w(
+ LOG_TAG,
+ "UIKitView.factory loadUrl id=$instanceId url=${shortUrl(authorizeUrl)}",
+ )
+ loadRequest(NSURLRequest(uRL = NSURL(string = authorizeUrl)))
+ heldWebView[0] = this
+ }
+ },
+ modifier = Modifier.fillMaxSize(),
+ update = { wv ->
+ heldWebView[0] = wv
+ updateCanGoBack(wv.canGoBack)
+ },
+ )
+
+ AnimatedVisibility(
+ visible = showLoadingOverlay,
+ enter = fadeIn(tween(0)),
+ exit = fadeOut(tween(LOADING_FADE_MS)),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(fallbackColor),
+ contentAlignment = Alignment.Center,
+ ) {
+ LoadingIndicator()
+ }
+ }
}
}
+
+/**
+ * Keep Yandex ID / OAuth / captcha flows in the WebView; open everything else externally.
+ * iOS copy of Android [isYandexAuthNavigation] (androidMain-only there).
+ */
+private fun isYandexAuthNavigationIos(url: String): Boolean {
+ if (url.startsWith("fromchat://", ignoreCase = true)) return true
+ val nsUrl = NSURL.URLWithString(url) ?: return false
+ val host = nsUrl.host?.lowercase() ?: return false
+ val path = nsUrl.path.orEmpty().lowercase()
+
+ if (host == "yandex.ru" || host == "www.yandex.ru" || host == "ya.ru" || host == "www.ya.ru") {
+ return path.contains("captcha") ||
+ path.startsWith("/auth") ||
+ path.startsWith("/showcaptcha") ||
+ path.startsWith("/checkcaptcha")
+ }
+
+ return host == "oauth.yandex.com" ||
+ host == "oauth.yandex.ru" ||
+ host.endsWith(".oauth.yandex.com") ||
+ host.endsWith(".oauth.yandex.ru") ||
+ host == "passport.yandex.ru" ||
+ host == "passport.yandex.com" ||
+ host.endsWith(".passport.yandex.ru") ||
+ host.endsWith(".passport.yandex.com") ||
+ host.startsWith("auth.yandex.") ||
+ host.startsWith("login.yandex.") ||
+ host.startsWith("id.yandex.") ||
+ host == "sso.passport.yandex.ru" ||
+ host == "captcha.yandex.net" ||
+ host.endsWith(".captcha.yandex.net") ||
+ (host.contains("captcha") && host.contains("yandex"))
+}
+
+private fun openExternalUrl(url: String) {
+ val nsUrl = NSURL.URLWithString(url) ?: return
+ runCatching { UIApplication.sharedApplication.openURL(nsUrl) }
+}
+
+private fun clearYandexWebViewCookiesIos() {
+ val storage = NSHTTPCookieStorage.sharedHTTPCookieStorage
+ storage.cookies?.filterIsInstance()?.forEach { storage.deleteCookie(it) }
+ Logger.i(LOG_TAG, "cleared WebView cookies for Yandex re-auth")
+}
+
+private fun seedYandexThemeCookiesIos(darkTheme: Boolean) {
+ val theme = if (darkTheme) "dark" else "light"
+ val storage = NSHTTPCookieStorage.sharedHTTPCookieStorage
+ val hosts = listOf(
+ "yandex.ru",
+ "yandex.com",
+ "passport.yandex.ru",
+ "passport.yandex.com",
+ "oauth.yandex.ru",
+ "oauth.yandex.com",
+ )
+ for (domain in hosts) {
+ for (nameValue in listOf("color_scheme=$theme", "theme=$theme", "yh=Theme=$theme")) {
+ val props: Map = mapOf(
+ "Name" to nameValue.substringBefore('='),
+ "Value" to nameValue.substringAfter('='),
+ "Domain" to domain,
+ "Path" to "/",
+ )
+ NSHTTPCookie.cookieWithProperties(props)?.let { storage.setCookie(it) }
+ }
+ }
+}
+
+private fun Color.toUIColor(): UIColor =
+ UIColor(
+ red = red.toDouble(),
+ green = green.toDouble(),
+ blue = blue.toDouble(),
+ alpha = alpha.toDouble(),
+ )
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.ios.kt
index 8e1f785..f94d8fb 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.ios.kt
@@ -1,11 +1,43 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
package ru.fromchat.ui.calls
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.UIKitView
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.ObjCSignatureOverride
+import platform.Foundation.NSError
+import platform.WebKit.WKNavigation
+import platform.WebKit.WKNavigationDelegateProtocol
+import platform.WebKit.WKScriptMessage
+import platform.WebKit.WKScriptMessageHandlerProtocol
+import platform.WebKit.WKUserContentController
+import platform.WebKit.WKWebView
+import platform.WebKit.WKWebViewConfiguration
+import platform.WebKit.WKAudiovisualMediaTypeNone
+import platform.CoreGraphics.CGRectMake
+import platform.darwin.NSObject
+import ru.fromchat.Logger
+import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.calls.LiveKitConnectSession
+private const val TAG = "CallMediaLayer"
+private const val HANDLER_NAME = "FromChatCall"
+
@Composable
actual fun CallMediaLayer(
connect: LiveKitConnectSession?,
@@ -13,5 +45,182 @@ actual fun CallMediaLayer(
showInCallControls: Boolean,
modifier: Modifier,
) {
- Box(modifier = modifier.fillMaxSize())
+ when {
+ connect == null && showDialingPlaceholder -> {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceContainerHighest),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
+ }
+ }
+ connect != null -> {
+ key(connect.roomName) {
+ LiveKitWkWebCall(
+ session = connect,
+ showInCallControls = showInCallControls,
+ modifier = modifier,
+ )
+ }
+ }
+ else -> Box(modifier = modifier.fillMaxSize())
+ }
+}
+
+@Composable
+private fun LiveKitWkWebCall(
+ session: LiveKitConnectSession,
+ showInCallControls: Boolean,
+ modifier: Modifier,
+) {
+ var micOn by remember(session.roomName) { mutableStateOf(true) }
+ var camOn by remember(session.roomName) { mutableStateOf(true) }
+ val sessionState = rememberUpdatedState(session)
+ val heldWebView = remember { arrayOfNulls(1) }
+ val instanceId = remember { (100000..999999).random().toString(16) }
+
+ val messageHandler = remember {
+ object : NSObject(), WKScriptMessageHandlerProtocol {
+ override fun userContentController(
+ userContentController: WKUserContentController,
+ didReceiveScriptMessage: WKScriptMessage,
+ ) {
+ if (didReceiveScriptMessage.name != HANDLER_NAME) return
+ val map = didReceiveScriptMessage.body as? Map<*, *> ?: return
+ when (map["method"] as? String) {
+ "onConnected" -> {
+ Logger.i(TAG, "LiveKit JS connected id=$instanceId room=${sessionState.value.roomName}")
+ }
+ "onConnectFailed" -> {
+ val message = (map["message"] as? String).orEmpty()
+ Logger.e(TAG, "LiveKit JS connect failed id=$instanceId: $message")
+ CallStore.onLiveKitConnectFailed(sessionState.value, message)
+ }
+ }
+ }
+ }
+ }
+
+ val navDelegate = remember {
+ object : NSObject(), WKNavigationDelegateProtocol {
+ @ObjCSignatureOverride
+ override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) {
+ Logger.i(TAG, "didFinishNavigation id=$instanceId → connect")
+ webView.evaluateJavaScript(
+ LiveKitCallWebPage.connectScript(
+ sessionState.value.serverUrl,
+ sessionState.value.token,
+ ),
+ completionHandler = { _, error ->
+ if (error != null) {
+ Logger.e(
+ TAG,
+ "connect script failed id=$instanceId: ${error.localizedDescription}",
+ )
+ CallStore.onLiveKitConnectFailed(
+ sessionState.value,
+ error.localizedDescription,
+ )
+ }
+ },
+ )
+ }
+
+ @ObjCSignatureOverride
+ override fun webView(
+ webView: WKWebView,
+ didFailNavigation: WKNavigation?,
+ withError: NSError,
+ ) {
+ Logger.w(TAG, "didFailNavigation id=$instanceId: ${withError.localizedDescription}")
+ CallStore.onLiveKitConnectFailed(sessionState.value, withError.localizedDescription)
+ }
+
+ @ObjCSignatureOverride
+ override fun webView(
+ webView: WKWebView,
+ didFailProvisionalNavigation: WKNavigation?,
+ withError: NSError,
+ ) {
+ Logger.w(
+ TAG,
+ "didFailProvisionalNavigation id=$instanceId: ${withError.localizedDescription}",
+ )
+ CallStore.onLiveKitConnectFailed(sessionState.value, withError.localizedDescription)
+ }
+ }
+ }
+
+ DisposableEffect(session.roomName) {
+ Logger.i(TAG, "WKWebView call enter id=$instanceId room=${session.roomName}")
+ onDispose {
+ Logger.i(TAG, "WKWebView call dispose id=$instanceId")
+ heldWebView[0]?.let { wv ->
+ wv.evaluateJavaScript(LiveKitCallWebPage.disconnectScript(), null)
+ wv.configuration.userContentController
+ .removeScriptMessageHandlerForName(HANDLER_NAME)
+ wv.setNavigationDelegate(null)
+ wv.stopLoading()
+ }
+ heldWebView[0] = null
+ }
+ }
+
+ Box(modifier = modifier.fillMaxSize()) {
+ UIKitView(
+ factory = {
+ val contentController = WKUserContentController().apply {
+ addScriptMessageHandler(messageHandler, name = HANDLER_NAME)
+ }
+ val config = WKWebViewConfiguration().apply {
+ setUserContentController(contentController)
+ allowsInlineMediaPlayback = true
+ mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone
+ }
+ WKWebView(
+ frame = CGRectMake(0.0, 0.0, 0.0, 0.0),
+ configuration = config,
+ ).apply {
+ opaque = true
+ setNavigationDelegate(navDelegate)
+ loadHTMLString(
+ LiveKitCallWebPage.html,
+ baseURL = platform.Foundation.NSURL(string = "https://cdn.jsdelivr.net/"),
+ )
+ heldWebView[0] = this
+ }
+ },
+ modifier = Modifier.fillMaxSize(),
+ update = { wv ->
+ heldWebView[0] = wv
+ },
+ )
+
+ if (showInCallControls) {
+ WebCallInCallControls(
+ micOn = micOn,
+ camOn = camOn,
+ onMicToggle = {
+ val next = !micOn
+ micOn = next
+ heldWebView[0]?.evaluateJavaScript(
+ LiveKitCallWebPage.setMicScript(next),
+ null,
+ )
+ },
+ onCamToggle = {
+ val next = !camOn
+ camOn = next
+ heldWebView[0]?.evaluateJavaScript(
+ LiveKitCallWebPage.setCamScript(next),
+ null,
+ )
+ },
+ onEndCall = { CallStore.endCall() },
+ modifier = Modifier.align(Alignment.BottomCenter),
+ )
+ }
+ }
}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.ios.kt
index 81c7c67..ef19904 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.ios.kt
@@ -1,3 +1,7 @@
package ru.fromchat.ui.calls
-actual val UseInlineInCallChrome: Boolean = false
+/**
+ * When true, [CallMediaLayer] owns in-call mic/camera/end chrome (Android native +
+ * WebView-based iOS/desktop). Call overlays still pass [showInCallControls].
+ */
+actual val UseInlineInCallChrome: Boolean = true
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/NotifyIncomingCall.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/NotifyIncomingCall.ios.kt
new file mode 100644
index 0000000..5dab692
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/calls/NotifyIncomingCall.ios.kt
@@ -0,0 +1,3 @@
+package ru.fromchat.ui.calls
+
+internal actual fun notifyIncomingCall(title: String, body: String) = Unit
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.ios.kt
index dafb16f..c4602a5 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.ios.kt
@@ -1,21 +1,218 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
package ru.fromchat.ui.chat.utils
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.useContents
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import platform.Foundation.NSCachesDirectory
+import platform.Foundation.NSFileManager
+import platform.Foundation.NSLock
+import platform.Foundation.NSSearchPathForDirectoriesInDomains
+import platform.Foundation.NSURL
+import platform.Foundation.NSUUID
+import platform.Foundation.NSUserDomainMask
+import platform.PhotosUI.PHPickerConfiguration
+import platform.PhotosUI.PHPickerFilter
+import platform.PhotosUI.PHPickerResult
+import platform.PhotosUI.PHPickerViewController
+import platform.PhotosUI.PHPickerViewControllerDelegateProtocol
+import platform.UIKit.UIDocumentPickerDelegateProtocol
+import platform.UIKit.UIDocumentPickerViewController
+import platform.UIKit.UIImage
+import platform.UniformTypeIdentifiers.UTTypeImage
+import platform.UniformTypeIdentifiers.UTTypeItem
+import platform.darwin.NSObject
+import platform.darwin.dispatch_async
+import platform.darwin.dispatch_get_main_queue
+import ru.fromchat.utils.iosTopViewController
actual fun getFilenameFromUri(uri: String): String {
- return uri.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
+ val path = when {
+ uri.startsWith("file://") -> NSURL.URLWithString(uri)?.path ?: uri
+ else -> uri
+ }
+ return path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
}
@Composable
actual fun rememberImagePicker(onResult: (List) -> Unit): () -> Unit {
- return { /* Phase 4: PHPickerViewController */ }
+ var activeDelegate by remember { mutableStateOf(null) }
+ return remember(onResult) {
+ {
+ val host = iosTopViewController() ?: return@remember
+ val picker = PHPickerViewController(
+ configuration = PHPickerConfiguration().apply {
+ filter = PHPickerFilter.imagesFilter
+ selectionLimit = 0
+ },
+ )
+ val delegate = ImagePickerDelegate { uris ->
+ activeDelegate = null
+ onResult(uris)
+ }
+ activeDelegate = delegate
+ picker.delegate = delegate
+ host.presentViewController(picker, animated = true, completion = null)
+ }
+ }
}
@Composable
actual fun rememberFilePicker(onResult: (List) -> Unit): () -> Unit {
- return { /* Phase 4: UIDocumentPickerViewController */ }
+ var activeDelegate by remember { mutableStateOf(null) }
+ return remember(onResult) {
+ {
+ val host = iosTopViewController() ?: return@remember
+ val picker = UIDocumentPickerViewController(
+ forOpeningContentTypes = listOf(UTTypeItem),
+ asCopy = true,
+ ).apply {
+ allowsMultipleSelection = true
+ }
+ val delegate = FilePickerDelegate { uris ->
+ activeDelegate = null
+ onResult(uris)
+ }
+ activeDelegate = delegate
+ picker.delegate = delegate
+ host.presentViewController(picker, animated = true, completion = null)
+ }
+ }
}
-actual suspend fun getImageAspectRatio(uri: String): Float? = null
+actual suspend fun getImageAspectRatio(uri: String): Float? {
+ val (w, h) = getImageDimensions(uri) ?: return null
+ if (h == 0) return null
+ return w.toFloat() / h.toFloat()
+}
-actual suspend fun getImageDimensions(uri: String): Pair? = null
+actual suspend fun getImageDimensions(uri: String): Pair? = withContext(Dispatchers.Default) {
+ val path = when {
+ uri.startsWith("file://") -> NSURL.URLWithString(uri)?.path
+ else -> uri
+ } ?: return@withContext null
+ runCatching {
+ val image = UIImage.imageWithContentsOfFile(path) ?: return@runCatching null
+ pixelSize(image)
+ }.getOrNull()
+}
+
+private fun pixelSize(image: UIImage): Pair? {
+ val w = (image.size.useContents { width } * image.scale).toInt()
+ val h = (image.size.useContents { height } * image.scale).toInt()
+ if (w <= 0 || h <= 0) return null
+ return w to h
+}
+
+private fun pickerCacheDirectory(): String {
+ val caches = NSSearchPathForDirectoriesInDomains(
+ NSCachesDirectory,
+ NSUserDomainMask,
+ true,
+ ).filterIsInstance().firstOrNull().orEmpty()
+ val dir = "$caches/fromchat/picker"
+ NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null)
+ return dir
+}
+
+private fun copyUrlIntoPickerCache(url: NSURL): String? {
+ val name = url.lastPathComponent?.takeIf { it.isNotBlank() } ?: "file"
+ val destPath = "${pickerCacheDirectory()}/${NSUUID().UUIDString}_$name"
+ val destUrl = NSURL.fileURLWithPath(destPath)
+ NSFileManager.defaultManager.removeItemAtPath(destPath, null)
+ if (!NSFileManager.defaultManager.copyItemAtURL(url, toURL = destUrl, error = null)) {
+ return null
+ }
+ return destUrl.absoluteString
+}
+
+private class ImagePickerDelegate(
+ private val onFinished: (List) -> Unit,
+) : NSObject(), PHPickerViewControllerDelegateProtocol {
+ private var finished = false
+ private val lock = NSLock()
+
+ override fun picker(picker: PHPickerViewController, didFinishPicking: List<*>) {
+ picker.dismissViewControllerAnimated(true, completion = null)
+ val results = didFinishPicking.filterIsInstance()
+ if (results.isEmpty()) {
+ finish(emptyList())
+ return
+ }
+ val collected = arrayOfNulls(results.size)
+ var remaining = results.size
+ val typeId = UTTypeImage.identifier
+ results.forEachIndexed { index, result ->
+ val provider = result.itemProvider
+ if (!provider.hasItemConformingToTypeIdentifier(typeId)) {
+ val done = onItemLoaded {
+ remaining -= 1
+ remaining == 0
+ }
+ if (done) finish(collected.filterNotNull())
+ return@forEachIndexed
+ }
+ provider.loadFileRepresentationForTypeIdentifier(typeId) { url, _ ->
+ collected[index] = url?.let { copyUrlIntoPickerCache(it) }
+ val done = onItemLoaded {
+ remaining -= 1
+ remaining == 0
+ }
+ if (done) {
+ dispatch_async(dispatch_get_main_queue()) {
+ finish(collected.filterNotNull())
+ }
+ }
+ }
+ }
+ }
+
+ private inline fun onItemLoaded(block: () -> Boolean): Boolean {
+ lock.lock()
+ return try {
+ block()
+ } finally {
+ lock.unlock()
+ }
+ }
+
+ private fun finish(uris: List) {
+ if (finished) return
+ finished = true
+ onFinished(uris)
+ }
+}
+
+private class FilePickerDelegate(
+ private val onFinished: (List) -> Unit,
+) : NSObject(), UIDocumentPickerDelegateProtocol {
+ private var finished = false
+
+ private fun finish(uris: List) {
+ if (finished) return
+ finished = true
+ onFinished(uris)
+ }
+
+ override fun documentPicker(
+ controller: UIDocumentPickerViewController,
+ didPickDocumentsAtURLs: List<*>,
+ ) {
+ val uris = didPickDocumentsAtURLs.mapNotNull { raw ->
+ val url = raw as? NSURL ?: return@mapNotNull null
+ copyUrlIntoPickerCache(url) ?: url.absoluteString
+ }
+ finish(uris)
+ }
+
+ override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) {
+ finish(emptyList())
+ }
+}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/components/BackHandler.ios.kt
similarity index 82%
rename from app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt
rename to app/shared/src/iosMain/kotlin/ru/fromchat/ui/components/BackHandler.ios.kt
index 31299fd..28a5244 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/components/BackHandler.ios.kt
@@ -1,4 +1,4 @@
-package ru.fromchat.ui
+package ru.fromchat.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -14,8 +14,8 @@ actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
@OptIn(ExperimentalForeignApi::class)
@Composable
-actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
- return remember {
+actual fun rememberHapticFeedbackInternal(): (Int) -> Unit =
+ remember {
{ ordinal ->
val style: UIImpactFeedbackStyle = when (ordinal) {
HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
@@ -23,8 +23,6 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
HapticFeedbackEvent.SelectionModeEntered.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight
}
- val generator = UIImpactFeedbackGenerator(style)
- generator.impactOccurred()
+ UIImpactFeedbackGenerator(style).impactOccurred()
}
}
-}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.ios.kt
deleted file mode 100644
index 6668c61..0000000
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.ios.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package ru.fromchat.ui.debug
-
-import androidx.compose.runtime.Composable
-
-@Composable
-actual fun DebugApiScreen() {
- DebugApiScreenContent()
-}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.ios.kt
index cbc2119..cecc142 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.ios.kt
@@ -11,6 +11,6 @@ actual fun openAppNotificationSettings(): Boolean {
return UIApplication.sharedApplication.openURL(url)
}
-actual fun areAppNotificationsEnabled(): Boolean {
- return true
-}
\ No newline at end of file
+actual fun areAppNotificationsEnabled(): Boolean = false
+
+actual fun arePushNotificationsSupported(): Boolean = false
\ No newline at end of file
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/utils/NetworkConnectivity.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/utils/NetworkConnectivity.ios.kt
index 8533d01..56b5f64 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/utils/NetworkConnectivity.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/utils/NetworkConnectivity.ios.kt
@@ -1,15 +1,44 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
package ru.fromchat.utils
+import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import platform.Network.nw_path_get_status
+import platform.Network.nw_path_monitor_create
+import platform.Network.nw_path_monitor_set_queue
+import platform.Network.nw_path_monitor_set_update_handler
+import platform.Network.nw_path_monitor_start
+import platform.Network.nw_path_monitor_t
+import platform.Network.nw_path_status_satisfied
+import platform.darwin.dispatch_get_main_queue
+import ru.fromchat.api.local.WebSocketManager
-// TODO iOS: wired later to NWPathMonitor; default [true] avoids false "offline" when APIs are absent.
actual object NetworkConnectivity {
private val _isOnline = MutableStateFlow(true)
actual val isOnline: StateFlow = _isOnline.asStateFlow()
+ private var started = false
+ // Retained so the path monitor is not collected while the process is alive.
+ private var monitor: nw_path_monitor_t = null
+
actual fun ensureStarted() {
- // No-op until native path monitoring is bound.
+ if (started) return
+ started = true
+ val pathMonitor = nw_path_monitor_create() ?: return
+ monitor = pathMonitor
+ nw_path_monitor_set_update_handler(pathMonitor) { path ->
+ val online = nw_path_get_status(path) == nw_path_status_satisfied
+ val wasOnline = _isOnline.value
+ _isOnline.value = online
+ when {
+ online && !wasOnline -> WebSocketManager.onNetworkAvailable()
+ !online && wasOnline -> WebSocketManager.onNetworkLost()
+ }
+ }
+ nw_path_monitor_set_queue(pathMonitor, dispatch_get_main_queue())
+ nw_path_monitor_start(pathMonitor)
}
}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/KeepWebSocketAlive.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/KeepWebSocketAlive.jvm.kt
new file mode 100644
index 0000000..d4abcdd
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/KeepWebSocketAlive.jvm.kt
@@ -0,0 +1,3 @@
+package ru.fromchat
+
+actual fun keepWebSocketAliveInBackground(): Boolean = true
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/Logger.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/Logger.jvm.kt
new file mode 100644
index 0000000..29fba7b
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/Logger.jvm.kt
@@ -0,0 +1,33 @@
+package ru.fromchat
+
+import ru.fromchat.logging.AppLogLevel
+import ru.fromchat.logging.AppLogStore
+
+actual object Logger {
+ actual fun d(tag: String, message: String, throwable: Throwable?) {
+ AppLogStore.record(AppLogLevel.Debug, tag, message, throwable)
+ println("DEBUG: [$tag] $message ${throwable?.message ?: ""}")
+ }
+
+ actual fun i(tag: String, message: String, throwable: Throwable?) {
+ AppLogStore.record(AppLogLevel.Info, tag, message, throwable)
+ println("INFO: [$tag] $message ${throwable?.message ?: ""}")
+ }
+
+ actual fun w(tag: String, message: String, throwable: Throwable?) {
+ AppLogStore.record(AppLogLevel.Warn, tag, message, throwable)
+ System.err.println("WARN: [$tag] $message ${throwable?.message ?: ""}")
+ }
+
+ actual fun e(tag: String, message: String, throwable: Throwable?) {
+ AppLogStore.record(AppLogLevel.Error, tag, message, throwable)
+ System.err.println("ERROR: [$tag] $message ${throwable?.message ?: ""}")
+ throwable?.printStackTrace()
+ }
+
+ actual fun f(tag: String, message: String, throwable: Throwable?) {
+ AppLogStore.record(AppLogLevel.Fatal, tag, message, throwable)
+ System.err.println("FATAL: [$tag] $message ${throwable?.message ?: ""}")
+ throwable?.printStackTrace()
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/ApiClient.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/ApiClient.jvm.kt
new file mode 100644
index 0000000..13ad00d
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/ApiClient.jvm.kt
@@ -0,0 +1,32 @@
+package ru.fromchat.api
+
+import io.ktor.client.HttpClient
+import io.ktor.client.HttpClientConfig
+import io.ktor.client.engine.cio.CIO
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+
+actual fun createPlatformHttpClient(
+ block: HttpClientConfig<*>.() -> Unit
+): HttpClient {
+ return HttpClient(CIO) {
+ defaultRequest {
+ contentType(ContentType.Application.Json)
+
+ headers {
+ append("Accept-Charset", "utf-8")
+ append("Content-Type", "application/json; charset=utf-8")
+ }
+ }
+
+ install(HttpTimeout) {
+ requestTimeoutMillis = 30_000
+ connectTimeoutMillis = 5_000
+ socketTimeoutMillis = 30_000
+ }
+
+ block(this)
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.jvm.kt
new file mode 100644
index 0000000..c7ba1d3
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.jvm.kt
@@ -0,0 +1,3 @@
+package ru.fromchat.api
+
+actual suspend fun syncPushTokenAfterStartup() = Unit
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/FcmRegistration.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/FcmRegistration.jvm.kt
new file mode 100644
index 0000000..d53b417
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/FcmRegistration.jvm.kt
@@ -0,0 +1,9 @@
+package ru.fromchat.api
+
+actual suspend fun uploadPendingFcmTokenIfAvailable() = Unit
+
+actual suspend fun ensureFcmTokenRegistered(): Boolean = false
+
+actual suspend fun unregisterFcmTokenFromServer(): Boolean = false
+
+actual suspend fun isFcmPushRegisteredLocally(): Boolean = false
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/backup/BackupCrypto.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/backup/BackupCrypto.jvm.kt
new file mode 100644
index 0000000..a2f25cb
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/backup/BackupCrypto.jvm.kt
@@ -0,0 +1,82 @@
+package ru.fromchat.api.crypto.backup
+
+import dev.whyoleg.cryptography.BinarySize.Companion.bits
+import dev.whyoleg.cryptography.BinarySize.Companion.bytes
+import dev.whyoleg.cryptography.CryptographyProvider
+import dev.whyoleg.cryptography.DelicateCryptographyApi
+import dev.whyoleg.cryptography.algorithms.AES
+import dev.whyoleg.cryptography.algorithms.PBKDF2
+import dev.whyoleg.cryptography.algorithms.SHA256
+import dev.whyoleg.cryptography.random.CryptographyRandom
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+private const val PBKDF2_ITERATIONS = 210_000
+private const val IV_SIZE = 12
+private const val KEY_SIZE_BYTES = 32
+
+@OptIn(DelicateCryptographyApi::class)
+actual object BackupCrypto {
+ private val provider get() = CryptographyProvider.Default
+ private val aesGcm get() = provider.get(AES.GCM)
+ private val pbkdf2 get() = provider.get(PBKDF2)
+
+ actual suspend fun encryptBackupWithPassword(
+ password: String,
+ bundle: PrivateKeyBundle
+ ): EncryptedBackupBlob = withContext(Dispatchers.Default) {
+ val salt = CryptographyRandom.nextBytes(16)
+ val nonce = CryptographyRandom.nextBytes(IV_SIZE)
+
+ EncryptedBackupBlob(
+ salt,
+ nonce,
+ aesGcm
+ .keyDecoder()
+ .decodeFromByteArray(
+ AES.Key.Format.RAW,
+ pbkdf2
+ .secretDerivation(
+ digest = SHA256,
+ iterations = PBKDF2_ITERATIONS,
+ outputSize = KEY_SIZE_BYTES.bytes,
+ salt = salt
+ )
+ .deriveSecretBlocking(
+ password.encodeToByteArray()
+ )
+ .toByteArray()
+ )
+ .cipher(tagSize = 128.bits)
+ .encryptWithIv(nonce, serializeBundle(bundle))
+ )
+ }
+
+ actual suspend fun decryptBackupWithPassword(
+ password: String,
+ blob: EncryptedBackupBlob
+ ): PrivateKeyBundle = withContext(Dispatchers.Default) {
+ deserializeBundle(
+ aesGcm
+ .keyDecoder()
+ .decodeFromByteArray(
+ AES.Key.Format.RAW,
+ pbkdf2
+ .secretDerivation(
+ digest = SHA256,
+ iterations = PBKDF2_ITERATIONS,
+ outputSize = KEY_SIZE_BYTES.bytes,
+ salt = blob.salt
+ )
+ .deriveSecretBlocking(
+ password.encodeToByteArray()
+ )
+ .toByteArray()
+ )
+ .cipher(tagSize = 128.bits)
+ .decryptWithIv(blob.iv, blob.ciphertext)
+ )
+ }
+
+ actual fun randomBytes(length: Int): ByteArray = CryptographyRandom.nextBytes(length)
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/dm/DmCrypto.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/dm/DmCrypto.jvm.kt
new file mode 100644
index 0000000..3513451
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/dm/DmCrypto.jvm.kt
@@ -0,0 +1,117 @@
+package ru.fromchat.api.crypto.dm
+
+import com.pr0gramm3r101.utils.crypto.Base64
+import com.pr0gramm3r101.utils.require
+import dev.whyoleg.cryptography.BinarySize.Companion.bits
+import dev.whyoleg.cryptography.CryptographyProvider
+import dev.whyoleg.cryptography.DelicateCryptographyApi
+import dev.whyoleg.cryptography.algorithms.AES
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import ru.fromchat.api.crypto.DmCiphertextCorruptedException
+
+private const val AES_KEY_SIZE = 32
+private const val GCM_IV_SIZE = 12
+private const val GCM_TAG_SIZE = 16
+
+@OptIn(DelicateCryptographyApi::class)
+actual object DmCrypto {
+ private val provider get() = CryptographyProvider.Default
+ private val aesGcm get() = provider.get(AES.GCM)
+
+ actual suspend fun unwrapMek(
+ wrappedMekB64: String,
+ wrappingKey: ByteArray
+ ) = withContext(Dispatchers.Default) {
+ require(wrappingKey.size == AES_KEY_SIZE) { "Wrapping key must be 32 bytes" }
+ val wrapped = Base64.decode(wrappedMekB64)
+ require(wrapped.size >= GCM_IV_SIZE + GCM_TAG_SIZE) { "Wrapped MEK too short" }
+
+ try {
+ aesGcmDecrypt(
+ wrappingKey,
+ wrapped.sliceArray(0 until GCM_IV_SIZE),
+ wrapped.sliceArray(GCM_IV_SIZE until wrapped.size)
+ )
+ } catch (e: Throwable) {
+ throw DmCiphertextCorruptedException(cause = e)
+ }
+ }
+
+ actual suspend fun decryptEnvelope(
+ ivB64: String,
+ ciphertextB64: String,
+ mek: ByteArray,
+ ) = withContext(Dispatchers.Default) {
+ val key = mek.require("MEK must be 32 bytes") {
+ it.size == AES_KEY_SIZE
+ }
+ val iv = Base64
+ .decode(ivB64)
+ .require("IV must be 12 bytes") {
+ it.size == GCM_IV_SIZE
+ }
+ val ciphertext = Base64
+ .decode(ciphertextB64)
+ .require("Ciphertext too short") {
+ it.size >= GCM_TAG_SIZE
+ }
+ try {
+ aesGcmDecrypt(key, iv, ciphertext)
+ } catch (e: Throwable) {
+ throw DmCiphertextCorruptedException(cause = e)
+ }
+ }
+
+ actual suspend fun decryptAesGcm(
+ ivB64: String,
+ ciphertext: ByteArray,
+ mek: ByteArray,
+ ) = withContext(Dispatchers.Default) {
+ val key = mek.require("MEK must be 32 bytes") {
+ it.size == AES_KEY_SIZE
+ }
+ val iv = Base64
+ .decode(ivB64)
+ .require("IV must be 12 bytes") {
+ it.size == GCM_IV_SIZE
+ }
+ ciphertext.require("Ciphertext too short") {
+ it.size >= GCM_TAG_SIZE
+ }
+ try {
+ aesGcmDecrypt(key, iv, ciphertext)
+ } catch (e: Throwable) {
+ throw DmCiphertextCorruptedException(cause = e)
+ }
+ }
+
+ actual suspend fun decryptAesGcmFileToPath(
+ ivB64: String,
+ encryptedFilePath: String,
+ mek: ByteArray,
+ outputPath: String,
+ ): Long = withContext(Dispatchers.Default) {
+ val iv = Base64
+ .decode(ivB64)
+ .require("IV must be 12 bytes") {
+ it.size == GCM_IV_SIZE
+ }
+ DmFileOps.aesGcmDecryptFileToPath(iv, encryptedFilePath, mek, outputPath)
+ }
+
+ private suspend fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): ByteArray {
+ require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes for GCM" }
+ require(key.size == AES_KEY_SIZE) { "Key must be 32 bytes" }
+ require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" }
+
+ return aesGcm
+ .keyDecoder()
+ .decodeFromByteArray(
+ AES.Key.Format.RAW,
+ key
+ )
+ .cipher(tagSize = 128.bits)
+ .decryptWithIv(iv, ciphertext)
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/dm/PlatformAesGcmStreamDecrypt.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/dm/PlatformAesGcmStreamDecrypt.jvm.kt
new file mode 100644
index 0000000..d2f7e7f
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/dm/PlatformAesGcmStreamDecrypt.jvm.kt
@@ -0,0 +1,117 @@
+package ru.fromchat.api.crypto.dm
+
+import com.pr0gramm3r101.utils.files.PlatformFileSystem
+import dev.whyoleg.cryptography.BinarySize.Companion.bits
+import dev.whyoleg.cryptography.CryptographyProvider
+import dev.whyoleg.cryptography.DelicateCryptographyApi
+import dev.whyoleg.cryptography.algorithms.AES
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import kotlinx.io.Buffer
+import kotlinx.io.RawSink
+import kotlinx.io.RawSource
+import kotlinx.io.buffered
+
+private const val AES_KEY_SIZE = 32
+private const val GCM_IV_SIZE = 12
+private const val GCM_TAG_SIZE = 16
+private const val COPY_BUFFER_BYTES = 256 * 1024
+
+@OptIn(DelicateCryptographyApi::class)
+internal actual suspend fun platformAesGcmStreamDecryptMekFile(
+ iv: ByteArray,
+ encryptedPath: String,
+ key: ByteArray,
+ outputPath: String,
+): Long {
+ require(key.size == AES_KEY_SIZE) { "MEK must be 32 bytes" }
+ require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" }
+ val encryptedSize = PlatformFileSystem.fileSize(encryptedPath)
+ require(encryptedSize >= GCM_TAG_SIZE) { "Ciphertext too short" }
+
+ val parent = outputPath.substringBeforeLast('/', missingDelimiterValue = "")
+ if (parent.isNotEmpty()) {
+ PlatformFileSystem.ensureDirectory(parent)
+ }
+ if (PlatformFileSystem.exists(outputPath)) {
+ PlatformFileSystem.delete(outputPath)
+ }
+
+ val aesKey = CryptographyProvider.Default
+ .get(AES.GCM)
+ .keyDecoder()
+ .decodeFromByteArray(AES.Key.Format.RAW, key)
+ val cipher = aesKey.cipher(tagSize = 128.bits)
+
+ FileEncryptedRawSource(encryptedPath).buffered().use { encryptedSource ->
+ FileRawSink(outputPath).buffered().use { plainSink ->
+ cipher.decryptingSinkWithIv(iv, plainSink).buffered().use { decryptSink ->
+ pumpRawSourceToSink(encryptedSource, decryptSink)
+ }
+ }
+ }
+
+ val plaintextBytes = PlatformFileSystem.fileSize(outputPath)
+ require(plaintextBytes > 0L) { "Decrypted file is empty" }
+ return plaintextBytes
+}
+
+private suspend fun pumpRawSourceToSink(source: RawSource, sink: RawSink) {
+ val chunk = Buffer()
+ while (true) {
+ val read = source.readAtMostTo(chunk, COPY_BUFFER_BYTES.toLong())
+ if (read < 0L) break
+ if (read == 0L) continue
+ sink.write(chunk, read)
+ }
+ sink.flush()
+}
+
+private class FileEncryptedRawSource(
+ path: String,
+) : RawSource {
+ private val input = FileInputStream(File(path))
+
+ override fun readAtMostTo(sink: Buffer, byteCount: Long): Long {
+ if (byteCount <= 0L) return 0L
+ val toRead = byteCount.coerceAtMost(COPY_BUFFER_BYTES.toLong()).toInt()
+ val array = ByteArray(toRead)
+ val read = input.read(array)
+ if (read <= 0) return -1L
+ sink.write(array, 0, read)
+ return read.toLong()
+ }
+
+ override fun close() {
+ input.close()
+ }
+}
+
+private class FileRawSink(
+ path: String,
+) : RawSink {
+ private val output = FileOutputStream(File(path))
+
+ override fun write(source: Buffer, byteCount: Long) {
+ if (byteCount <= 0L) return
+ var remaining = byteCount
+ while (remaining > 0L) {
+ val toRead = minOf(remaining, COPY_BUFFER_BYTES.toLong()).toInt()
+ val array = ByteArray(toRead)
+ val read = source.readAtMostTo(array, startIndex = 0, endIndex = toRead)
+ if (read <= 0) break
+ output.write(array, 0, read)
+ remaining -= read
+ }
+ }
+
+ override fun flush() {
+ output.flush()
+ }
+
+ override fun close() {
+ output.flush()
+ output.close()
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/transport/TransportCrypto.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/transport/TransportCrypto.jvm.kt
new file mode 100644
index 0000000..259c99b
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/transport/TransportCrypto.jvm.kt
@@ -0,0 +1,86 @@
+@file:OptIn(ExperimentalUnsignedTypes::class)
+
+package ru.fromchat.api.crypto.transport
+
+import com.ionspin.kotlin.crypto.LibsodiumInitializer
+import com.ionspin.kotlin.crypto.box.Box
+import com.ionspin.kotlin.crypto.box.crypto_box_NONCEBYTES
+import com.ionspin.kotlin.crypto.util.LibsodiumRandom
+import com.pr0gramm3r101.utils.crypto.Base64
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import ru.fromchat.api.crypto.transport.TransportCiphertext
+
+actual object TransportCrypto {
+ actual suspend fun encryptWithTransportKey(
+ plaintext: String,
+ transportPublicKeyB64: String
+ ) = withContext(Dispatchers.Default) {
+ if (!LibsodiumInitializer.isInitialized()) {
+ LibsodiumInitializer.initialize()
+ }
+ val keyPair = Box.keypair()
+ val nonce = LibsodiumRandom.buf(crypto_box_NONCEBYTES)
+ val ciphertext = Box.easy(
+ plaintext.encodeToByteArray().toUByteArray(),
+ nonce,
+ Base64.decode(transportPublicKeyB64).toUByteArray(),
+ keyPair.secretKey
+ )
+ val cipher = TransportCiphertext(
+ clientPublicKeyB64 = Base64.encode(keyPair.publicKey.toByteArray()),
+ nonceB64 = Base64.encode(nonce.toByteArray()),
+ ciphertextB64 = Base64.encode(ciphertext.toByteArray())
+ )
+ keyPair.secretKey.fill(0u)
+ cipher
+ }
+
+ actual suspend fun encryptWithTransportKeyWithEphemeralSecret(
+ plaintext: String,
+ transportPublicKeyB64: String
+ ): Pair = withContext(Dispatchers.Default) {
+ if (!LibsodiumInitializer.isInitialized()) {
+ LibsodiumInitializer.initialize()
+ }
+ val keyPair = Box.keypair()
+ val nonce = LibsodiumRandom.buf(crypto_box_NONCEBYTES)
+ val ciphertext = Box.easy(
+ plaintext.encodeToByteArray().toUByteArray(),
+ nonce,
+ Base64.decode(transportPublicKeyB64).toUByteArray(),
+ keyPair.secretKey
+ )
+ val cipher = TransportCiphertext(
+ clientPublicKeyB64 = Base64.encode(keyPair.publicKey.toByteArray()),
+ nonceB64 = Base64.encode(nonce.toByteArray()),
+ ciphertextB64 = Base64.encode(ciphertext.toByteArray())
+ )
+ val secretCopy = keyPair.secretKey.toByteArray().copyOf()
+ keyPair.secretKey.fill(0u)
+ cipher to secretCopy
+ }
+
+ actual suspend fun encryptFileForTransport(
+ fileBytes: ByteArray,
+ transportPublicKeyB64: String,
+ ephemeralSecretKey: ByteArray
+ ): ByteArray = withContext(Dispatchers.Default) {
+ if (!LibsodiumInitializer.isInitialized()) {
+ LibsodiumInitializer.initialize()
+ }
+ val nonce = LibsodiumRandom.buf(crypto_box_NONCEBYTES)
+ val ciphertext = Box.easy(
+ fileBytes.toUByteArray(),
+ nonce,
+ Base64.decode(transportPublicKeyB64).toUByteArray(),
+ ephemeralSecretKey.toUByteArray()
+ )
+ val nonceBytes = nonce.toByteArray()
+ val cipherBytes = ciphertext.toByteArray()
+ val result = ByteArray(nonceBytes.size + cipherBytes.size)
+ nonceBytes.copyInto(result, 0, 0, nonceBytes.size)
+ cipherBytes.copyInto(result, nonceBytes.size, 0, cipherBytes.size)
+ result
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileEncryptor.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileEncryptor.jvm.kt
new file mode 100644
index 0000000..6b14d22
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileEncryptor.jvm.kt
@@ -0,0 +1,32 @@
+package ru.fromchat.api.crypto.transport
+
+import java.io.File
+import java.io.FileOutputStream
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+actual object TransportFileEncryptor {
+ actual suspend fun encryptPlaintextFileToTransportBlob(
+ sourceUri: String,
+ destinationPath: String,
+ transportPublicKeyB64: String,
+ ephemeralSecretKey: ByteArray,
+ plaintextSizeBytes: Long,
+ onPlaintextProgress: ((bytesRead: Long, totalBytes: Long) -> Unit)?,
+ ): Long = withContext(Dispatchers.IO) {
+ val dest = File(destinationPath)
+ dest.parentFile?.mkdirs()
+ dest.delete()
+ FileOutputStream(dest).use { output ->
+ encryptPlaintextFileToFcaeBlob(
+ sourceUri = sourceUri,
+ writeBytes = { bytes -> output.write(bytes) },
+ finish = { dest.length() },
+ transportPublicKeyB64 = transportPublicKeyB64,
+ ephemeralSecretKey = ephemeralSecretKey,
+ plaintextSizeBytes = plaintextSizeBytes,
+ onPlaintextProgress = onPlaintextProgress,
+ )
+ }
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileKey.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileKey.jvm.kt
new file mode 100644
index 0000000..d397cc3
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/crypto/transport/TransportFileKey.jvm.kt
@@ -0,0 +1,55 @@
+package ru.fromchat.api.crypto.transport
+
+import com.ionspin.kotlin.crypto.LibsodiumInitializer
+import com.ionspin.kotlin.crypto.box.Box
+import com.pr0gramm3r101.utils.crypto.Base64
+import dev.whyoleg.cryptography.BinarySize.Companion.bits
+import dev.whyoleg.cryptography.CryptographyProvider
+import dev.whyoleg.cryptography.DelicateCryptographyApi
+import dev.whyoleg.cryptography.algorithms.AES
+import dev.whyoleg.cryptography.random.CryptographyRandom
+import java.util.concurrent.CountDownLatch
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+private const val IV_SIZE = 12
+
+@OptIn(DelicateCryptographyApi::class)
+private val aesGcm get() = CryptographyProvider.Default.get(AES.GCM)
+
+private fun ensureLibsodiumInitialized() {
+ if (LibsodiumInitializer.isInitialized()) return
+ val latch = CountDownLatch(1)
+ LibsodiumInitializer.initializeWithCallback { latch.countDown() }
+ latch.await()
+}
+
+@OptIn(ExperimentalUnsignedTypes::class)
+internal actual fun deriveTransportFileAesKey(
+ transportPublicKeyB64: String,
+ ephemeralSecretKey: ByteArray,
+): ByteArray {
+ ensureLibsodiumInitialized()
+ val transportPublicKey = Base64.decode(transportPublicKeyB64).toUByteArray()
+ val shared = Box.beforeNM(transportPublicKey, ephemeralSecretKey.toUByteArray()).toByteArray()
+ return hkdfTransportFileKey(shared)
+}
+
+@OptIn(DelicateCryptographyApi::class)
+internal actual suspend fun aesGcmEncryptChunk(
+ key: ByteArray,
+ plaintext: ByteArray,
+): Pair = withContext(Dispatchers.Default) {
+ val iv = CryptographyRandom.nextBytes(IV_SIZE)
+ val cipherKey = aesGcm.keyDecoder().decodeFromByteArray(AES.Key.Format.RAW, key)
+ val ciphertext = cipherKey.cipher(tagSize = 128.bits).encryptWithIv(iv, plaintext)
+ iv to ciphertext
+}
+
+internal actual fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray {
+ val mac = Mac.getInstance("HmacSHA256")
+ mac.init(SecretKeySpec(key, "HmacSHA256"))
+ return mac.doFinal(data)
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.jvm.kt
new file mode 100644
index 0000000..abc109c
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.jvm.kt
@@ -0,0 +1,31 @@
+package ru.fromchat.api.local.cache
+
+import java.awt.Image
+import java.awt.image.BufferedImage
+import java.io.File
+import javax.imageio.ImageIO
+
+actual fun generateAttachmentDiskThumbnail(
+ sourceAbsolutePath: String,
+ destAbsolutePath: String,
+ maxEdgePx: Int,
+): Boolean {
+ return runCatching {
+ val source = ImageIO.read(File(sourceAbsolutePath)) ?: return false
+ val width = source.width
+ val height = source.height
+ if (width <= 0 || height <= 0) return false
+ val longEdge = maxOf(width, height)
+ val scale = if (longEdge > maxEdgePx) maxEdgePx.toDouble() / longEdge else 1.0
+ val dstW = (width * scale).toInt().coerceAtLeast(1)
+ val dstH = (height * scale).toInt().coerceAtLeast(1)
+ val scaled = source.getScaledInstance(dstW, dstH, Image.SCALE_SMOOTH)
+ val buffered = BufferedImage(dstW, dstH, BufferedImage.TYPE_INT_RGB)
+ val graphics = buffered.createGraphics()
+ graphics.drawImage(scaled, 0, 0, null)
+ graphics.dispose()
+ val dest = File(destAbsolutePath)
+ dest.parentFile?.mkdirs()
+ ImageIO.write(buffered, "jpg", dest)
+ }.getOrDefault(false)
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/AttachmentUploadLimits.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/AttachmentUploadLimits.jvm.kt
new file mode 100644
index 0000000..f0a0619
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/AttachmentUploadLimits.jvm.kt
@@ -0,0 +1,5 @@
+package ru.fromchat.api.local.cache
+
+private const val JVM_MAX_BYTES = 64L * 1024L * 1024L
+
+actual fun maxInMemoryEncryptPlaintextBytes(): Long = JVM_MAX_BYTES
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FileWriteSink.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FileWriteSink.jvm.kt
new file mode 100644
index 0000000..0c014e5
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FileWriteSink.jvm.kt
@@ -0,0 +1,27 @@
+package ru.fromchat.api.local.cache
+
+import java.io.File
+import java.io.FileOutputStream
+
+internal actual class FileWriteSink actual constructor(
+ path: String,
+ append: Boolean,
+) : AutoCloseable {
+ private val output = FileOutputStream(File(path), append)
+
+ actual fun write(buffer: ByteArray, offset: Int, length: Int) {
+ if (length <= 0) return
+ output.write(buffer, offset, length)
+ }
+
+ actual fun flush() {
+ output.flush()
+ output.fd.sync()
+ }
+
+ actual override fun close() {
+ output.flush()
+ output.fd.sync()
+ output.close()
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.jvm.kt
new file mode 100644
index 0000000..74fa5eb
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.jvm.kt
@@ -0,0 +1,37 @@
+package ru.fromchat.api.local.cache
+
+import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+private fun cacheRoot(): File {
+ val home = System.getProperty("user.home")
+ return if (!home.isNullOrBlank()) {
+ File(home, ".fromchat/cache")
+ } else {
+ File(System.getProperty("java.io.tmpdir"), "fromchat")
+ }
+}
+
+actual suspend fun wipeFromChatCacheDirectory() {
+ withContext(Dispatchers.IO) {
+ File(cacheRoot(), "fromchat").deleteRecursively()
+ }
+}
+
+actual suspend fun wipeAttachmentCacheDirectories() {
+ withContext(Dispatchers.IO) {
+ val base = cacheRoot()
+ listOf("decrypted_images", "decrypted_files", "encrypted_downloads").forEach { name ->
+ File(base, name).deleteRecursively()
+ }
+ }
+}
+
+actual suspend fun wipeInstanceAuxiliaryCacheDirectory(instanceId: String) {
+ withContext(Dispatchers.IO) {
+ val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_")
+ if (safe.isEmpty()) return@withContext
+ File(cacheRoot(), "fromchat/instances/$safe").deleteRecursively()
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.jvm.kt
new file mode 100644
index 0000000..321a8eb
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheGeneration.jvm.kt
@@ -0,0 +1,38 @@
+package ru.fromchat.api.local.cache
+
+import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import ru.fromchat.api.local.db.store.MessageDatabaseProvider
+
+private const val GENERATION_FILE = ".generation"
+
+private fun fromChatRoot(): File {
+ val home = System.getProperty("user.home")
+ val base = if (!home.isNullOrBlank()) {
+ File(home, ".fromchat/cache")
+ } else {
+ File(System.getProperty("java.io.tmpdir"), "fromchat")
+ }
+ return File(base, "fromchat")
+}
+
+actual suspend fun ensureFromChatCacheGeneration() {
+ withContext(Dispatchers.IO) {
+ val root = fromChatRoot()
+ val marker = File(root, GENERATION_FILE)
+ if (marker.isFile) return@withContext
+ MessageDatabaseProvider.closeAndReset()
+ wipeFromChatCacheDirectory()
+ root.mkdirs()
+ marker.writeText("1\n")
+ }
+}
+
+actual suspend fun writeFromChatCacheGeneration() {
+ withContext(Dispatchers.IO) {
+ val root = fromChatRoot()
+ root.mkdirs()
+ File(root, GENERATION_FILE).writeText("1\n")
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/PendingFileSaveRegistry.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/PendingFileSaveRegistry.jvm.kt
new file mode 100644
index 0000000..41cc061
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/PendingFileSaveRegistry.jvm.kt
@@ -0,0 +1,38 @@
+package ru.fromchat.api.local.cache
+
+import java.io.File
+import java.net.URI
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import ru.fromchat.api.local.download.cachedAttachmentFileSize
+
+private val copyScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+internal actual fun enqueuePlatformCopy(storageKey: String) {
+ copyScope.launch {
+ val entry = PendingFileSaveRegistry.listPending()
+ .firstOrNull { it.storageKey == storageKey } ?: return@launch
+ val cacheUri = DecryptedFileCache.getCachedUriForStorageKey(storageKey) ?: return@launch
+ if (cachedAttachmentFileSize(cacheUri) <= 0L) return@launch
+ val bytes = runCatching { readOutboundFileBytes(cacheUri) }.getOrNull() ?: return@launch
+ if (bytes.isEmpty()) return@launch
+ if (writeBytesToDestinationUri(entry.destinationUri, bytes)) {
+ PendingFileSaveRegistry.remove(storageKey)
+ }
+ }
+}
+
+private fun writeBytesToDestinationUri(destinationUri: String, bytes: ByteArray): Boolean {
+ val path = when {
+ destinationUri.startsWith("file:") -> runCatching { URI(destinationUri).path }.getOrNull()
+ else -> destinationUri
+ } ?: return false
+ return runCatching {
+ val file = File(path)
+ file.parentFile?.mkdirs()
+ file.writeBytes(bytes)
+ true
+ }.getOrDefault(false)
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.jvm.kt
new file mode 100644
index 0000000..2497563
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.jvm.kt
@@ -0,0 +1,351 @@
+package ru.fromchat.api.local.cache
+
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.InputStream
+import java.net.URI
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+private fun cacheRoot(): File {
+ val home = System.getProperty("user.home")
+ return if (!home.isNullOrBlank()) {
+ File(home, ".fromchat/cache")
+ } else {
+ File(System.getProperty("java.io.tmpdir"), "fromchat")
+ }
+}
+
+private fun uploadDir(instanceId: String): File {
+ val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
+ return File(cacheRoot(), "fromchat/instances/$safe/uploads").apply { mkdirs() }
+}
+
+private fun safeId(clientMessageId: String): String =
+ clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
+
+private fun sourceFile(instanceId: String, clientMessageId: String): File =
+ File(uploadDir(instanceId), "${safeId(clientMessageId)}.source")
+
+private fun sourcePartFile(instanceId: String, clientMessageId: String): File =
+ File(uploadDir(instanceId), "${safeId(clientMessageId)}.source.part")
+
+private fun sourceOkFile(instanceId: String, clientMessageId: String): File =
+ File(uploadDir(instanceId), "${safeId(clientMessageId)}.source.ok")
+
+private fun blobFile(instanceId: String, clientMessageId: String): File =
+ File(uploadDir(instanceId), "${safeId(clientMessageId)}.enc")
+
+private fun blobPartFile(instanceId: String, clientMessageId: String): File =
+ File(uploadDir(instanceId), "${safeId(clientMessageId)}.enc.part")
+
+private fun blobOkFile(instanceId: String, clientMessageId: String): File =
+ File(uploadDir(instanceId), "${safeId(clientMessageId)}.enc.ok")
+
+private fun cipherFile(instanceId: String, clientMessageId: String): File =
+ File(uploadDir(instanceId), "${safeId(clientMessageId)}.cipher.json")
+
+private fun cipherPartFile(instanceId: String, clientMessageId: String): File =
+ File(uploadDir(instanceId), "${safeId(clientMessageId)}.cipher.json.part")
+
+actual fun encryptedUploadBlobPath(instanceId: String, clientMessageId: String): String =
+ blobFile(instanceId, clientMessageId).absolutePath
+
+actual fun encryptedUploadBlobPartPath(instanceId: String, clientMessageId: String): String =
+ blobPartFile(instanceId, clientMessageId).absolutePath
+
+private fun readOkMarker(okFile: File, diskFile: File, expectedBytes: Long): Boolean {
+ if (!okFile.isFile || !diskFile.isFile) return false
+ val marker = decodeUploadArtifactOkMarker(okFile.readText()) ?: return false
+ return marker.isValidOnDisk(diskFile.length(), expectedBytes)
+}
+
+private fun writeOkMarker(okFile: File, actualBytes: Long, expectedBytes: Long) {
+ okFile.writeText(encodeUploadArtifactOkMarker(actualBytes, expectedBytes))
+}
+
+private fun File.syncOutput() {
+ FileOutputStream(this, true).use { it.fd.sync() }
+}
+
+private fun atomicReplace(part: File, final: File) {
+ if (!part.isFile) error("Partial upload file missing")
+ final.delete()
+ if (!part.renameTo(final)) {
+ part.copyTo(final, overwrite = true)
+ part.delete()
+ }
+ final.syncOutput()
+}
+
+private fun resolveLocalPath(fileUri: String): String? =
+ when {
+ fileUri.startsWith("file:") -> runCatching { URI(fileUri).path }.getOrNull()
+ else -> fileUri
+ }
+
+actual suspend fun queryOutboundUriSizeBytes(fileUri: String): Long? = withContext(Dispatchers.IO) {
+ val path = resolveLocalPath(fileUri) ?: return@withContext null
+ val file = File(path)
+ if (!file.isFile) null else file.length()
+}
+
+actual suspend fun stageOutboundFileForUpload(
+ instanceId: String,
+ clientMessageId: String,
+ sourceUri: String,
+ expectedSizeBytes: Long,
+): StagedOutboundFile = withContext(Dispatchers.IO) {
+ repairInterruptedUploadArtifacts(instanceId, clientMessageId)
+ val dest = sourceFile(instanceId, clientMessageId)
+ val destUri = dest.absolutePath
+ if (sourceUri == destUri || resolveLocalPath(sourceUri) == destUri) {
+ if (!isStagedSourceReady(instanceId, clientMessageId, expectedSizeBytes)) {
+ throw OutboundFileUnavailableException("Staged source file is incomplete")
+ }
+ return@withContext StagedOutboundFile(uri = destUri, sizeBytes = dest.length())
+ }
+ if (isStagedSourceReady(instanceId, clientMessageId, expectedSizeBytes)) {
+ return@withContext StagedOutboundFile(uri = destUri, sizeBytes = dest.length())
+ }
+ val part = sourcePartFile(instanceId, clientMessageId)
+ dest.delete()
+ sourceOkFile(instanceId, clientMessageId).delete()
+ part.delete()
+ val sourcePath = resolveLocalPath(sourceUri)
+ ?: throw OutboundFileUnavailableException("Invalid file URI")
+ val source = File(sourcePath)
+ if (!source.isFile) throw OutboundFileUnavailableException("Failed to read file from URI")
+ source.inputStream().use { inputStream ->
+ FileOutputStream(part).use { output ->
+ inputStream.copyTo(output)
+ output.flush()
+ output.fd.sync()
+ }
+ }
+ atomicReplace(part, dest)
+ val stagedBytes = dest.length()
+ val expected = expectedSizeBytes.takeIf { it > 0L } ?: stagedBytes
+ if (expectedSizeBytes > 0L && stagedBytes != expectedSizeBytes) {
+ dest.delete()
+ sourceOkFile(instanceId, clientMessageId).delete()
+ throw OutboundFileUnavailableException("Staged file size mismatch")
+ }
+ writeOkMarker(sourceOkFile(instanceId, clientMessageId), stagedBytes, expected)
+ StagedOutboundFile(uri = destUri, sizeBytes = stagedBytes)
+}
+
+actual suspend fun isStagedSourceReady(
+ instanceId: String,
+ clientMessageId: String,
+ expectedSizeBytes: Long,
+): Boolean = withContext(Dispatchers.IO) {
+ readOkMarker(
+ sourceOkFile(instanceId, clientMessageId),
+ sourceFile(instanceId, clientMessageId),
+ expectedSizeBytes,
+ )
+}
+
+actual suspend fun isEncryptedBlobReady(
+ instanceId: String,
+ clientMessageId: String,
+ expectedEncryptedSizeBytes: Long?,
+): Boolean = withContext(Dispatchers.IO) {
+ val expected = expectedEncryptedSizeBytes?.takeIf { it > 0L } ?: 0L
+ val enc = blobFile(instanceId, clientMessageId)
+ val ok = blobOkFile(instanceId, clientMessageId)
+ if (!readOkMarker(ok, enc, expected)) return@withContext false
+ cipherFile(instanceId, clientMessageId).isFile
+}
+
+actual suspend fun commitEncryptedUploadBlob(
+ instanceId: String,
+ clientMessageId: String,
+ encryptedSizeBytes: Long,
+): Unit = withContext(Dispatchers.IO) {
+ val part = blobPartFile(instanceId, clientMessageId)
+ val final = blobFile(instanceId, clientMessageId)
+ if (part.isFile) {
+ atomicReplace(part, final)
+ } else if (!final.isFile) {
+ error("Encrypted upload blob missing")
+ }
+ if (final.length() != encryptedSizeBytes) {
+ throw OutboundFileUnavailableException("Encrypted blob size mismatch after commit")
+ }
+ writeOkMarker(blobOkFile(instanceId, clientMessageId), encryptedSizeBytes, encryptedSizeBytes)
+}
+
+actual suspend fun repairInterruptedUploadArtifacts(
+ instanceId: String,
+ clientMessageId: String,
+): Unit = withContext(Dispatchers.IO) {
+ sourcePartFile(instanceId, clientMessageId).delete()
+ blobPartFile(instanceId, clientMessageId).delete()
+ cipherPartFile(instanceId, clientMessageId).delete()
+ val enc = blobFile(instanceId, clientMessageId)
+ val encOk = blobOkFile(instanceId, clientMessageId)
+ if (!readOkMarker(encOk, enc, 0L)) {
+ enc.delete()
+ encOk.delete()
+ cipherFile(instanceId, clientMessageId).delete()
+ }
+ val source = sourceFile(instanceId, clientMessageId)
+ val sourceOk = sourceOkFile(instanceId, clientMessageId)
+ if (source.isFile && !readOkMarker(sourceOk, source, 0L)) {
+ source.delete()
+ sourceOk.delete()
+ }
+}
+
+private class JvmOutboundFileInputStream(
+ private val input: InputStream,
+) : OutboundFileInputStream {
+ override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int =
+ withContext(Dispatchers.IO) {
+ input.read(buffer, offset, length)
+ }
+
+ override suspend fun close() {
+ withContext(Dispatchers.IO) {
+ input.close()
+ }
+ }
+}
+
+actual suspend fun openOutboundFileInputStream(fileUri: String): OutboundFileInputStream? =
+ withContext(Dispatchers.IO) {
+ val path = resolveLocalPath(fileUri) ?: return@withContext null
+ val file = File(path)
+ if (!file.isFile) return@withContext null
+ JvmOutboundFileInputStream(FileInputStream(file))
+ }
+
+actual suspend fun readOutboundFileBytes(fileUri: String): ByteArray =
+ withContext(Dispatchers.IO) {
+ val path = resolveLocalPath(fileUri) ?: throw OutboundFileUnavailableException("Invalid file URI")
+ val file = File(path)
+ if (!file.isFile) throw OutboundFileUnavailableException("File no longer exists")
+ file.readBytes()
+ }
+
+actual suspend fun copyOutboundFileToPath(sourceUri: String, destinationPath: String) {
+ withContext(Dispatchers.IO) {
+ val dest = File(destinationPath)
+ dest.parentFile?.mkdirs()
+ val path = resolveLocalPath(sourceUri)
+ ?: throw OutboundFileUnavailableException("Invalid file URI")
+ val source = File(path)
+ if (!source.isFile) throw OutboundFileUnavailableException("Failed to read file from URI")
+ source.inputStream().use { inputStream ->
+ FileOutputStream(dest).use { output ->
+ inputStream.copyTo(output)
+ output.flush()
+ output.fd.sync()
+ }
+ }
+ }
+}
+
+actual suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray) {
+ withContext(Dispatchers.IO) {
+ repairInterruptedUploadArtifacts(instanceId, clientMessageId)
+ val part = blobPartFile(instanceId, clientMessageId)
+ part.delete()
+ blobOkFile(instanceId, clientMessageId).delete()
+ FileOutputStream(part).use { it.write(bytes) }
+ part.syncOutput()
+ }
+}
+
+actual suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray? =
+ withContext(Dispatchers.IO) {
+ if (!isEncryptedBlobReady(instanceId, clientMessageId, null)) return@withContext null
+ val f = blobFile(instanceId, clientMessageId)
+ if (!f.isFile || f.length() == 0L) null else f.readBytes()
+ }
+
+actual suspend fun encryptedUploadBlobSizeBytes(instanceId: String, clientMessageId: String): Long? =
+ withContext(Dispatchers.IO) {
+ if (!isEncryptedBlobReady(instanceId, clientMessageId, null)) return@withContext null
+ val f = blobFile(instanceId, clientMessageId)
+ if (!f.isFile || f.length() <= 0L) null else f.length()
+ }
+
+actual suspend fun readEncryptedUploadBlobRange(
+ instanceId: String,
+ clientMessageId: String,
+ offset: Long,
+ length: Int,
+): ByteArray = withContext(Dispatchers.IO) {
+ if (!isEncryptedBlobReady(instanceId, clientMessageId, null)) {
+ throw OutboundFileUnavailableException("Encrypted upload blob not committed")
+ }
+ val f = blobFile(instanceId, clientMessageId)
+ if (!f.isFile) throw OutboundFileUnavailableException("Encrypted upload blob missing")
+ if (length <= 0) return@withContext ByteArray(0)
+ f.inputStream().use { input ->
+ val skipped = input.skip(offset)
+ if (skipped < offset) throw OutboundFileUnavailableException("Encrypted upload blob truncated")
+ val buffer = ByteArray(length)
+ var read = 0
+ while (read < length) {
+ val n = input.read(buffer, read, length - read)
+ if (n <= 0) break
+ read += n
+ }
+ if (read < length) {
+ throw OutboundFileUnavailableException("Encrypted upload blob truncated")
+ }
+ buffer
+ }
+}
+
+actual suspend fun saveUploadTransportCipherJson(instanceId: String, clientMessageId: String, json: String) {
+ saveUploadTransportCipherJsonAtomic(instanceId, clientMessageId, json)
+}
+
+actual suspend fun saveUploadTransportCipherJsonAtomic(
+ instanceId: String,
+ clientMessageId: String,
+ json: String,
+) {
+ withContext(Dispatchers.IO) {
+ val part = cipherPartFile(instanceId, clientMessageId)
+ val final = cipherFile(instanceId, clientMessageId)
+ part.writeText(json)
+ part.syncOutput()
+ atomicReplace(part, final)
+ }
+}
+
+actual suspend fun loadUploadTransportCipherJson(instanceId: String, clientMessageId: String): String? =
+ withContext(Dispatchers.IO) {
+ if (!cipherFile(instanceId, clientMessageId).isFile) return@withContext null
+ cipherFile(instanceId, clientMessageId).readText().takeIf { it.isNotBlank() }
+ }
+
+actual suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String) {
+ withContext(Dispatchers.IO) {
+ sourceFile(instanceId, clientMessageId).delete()
+ sourcePartFile(instanceId, clientMessageId).delete()
+ sourceOkFile(instanceId, clientMessageId).delete()
+ blobFile(instanceId, clientMessageId).delete()
+ blobPartFile(instanceId, clientMessageId).delete()
+ blobOkFile(instanceId, clientMessageId).delete()
+ cipherFile(instanceId, clientMessageId).delete()
+ cipherPartFile(instanceId, clientMessageId).delete()
+ }
+}
+
+actual suspend fun clearUploadSecretsOnly(instanceId: String, clientMessageId: String) {
+ withContext(Dispatchers.IO) {
+ blobFile(instanceId, clientMessageId).delete()
+ blobPartFile(instanceId, clientMessageId).delete()
+ blobOkFile(instanceId, clientMessageId).delete()
+ cipherFile(instanceId, clientMessageId).delete()
+ cipherPartFile(instanceId, clientMessageId).delete()
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.jvm.kt
new file mode 100644
index 0000000..31bf25f
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.jvm.kt
@@ -0,0 +1,6 @@
+package ru.fromchat.api.local.db
+
+internal actual fun withMessageDatabaseLock(block: () -> T): T =
+ synchronized(MessageDatabaseLock) { block() }
+
+private object MessageDatabaseLock
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt
new file mode 100644
index 0000000..7ebbdc8
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt
@@ -0,0 +1,23 @@
+package ru.fromchat.api.local.db.store
+
+import app.cash.sqldelight.db.SqlDriver
+import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
+import java.io.File
+import ru.fromchat.db.MessageDatabase
+
+actual fun provideMessageDatabaseDriver(): SqlDriver {
+ val home = System.getProperty("user.home")
+ val dir = if (!home.isNullOrBlank()) {
+ File(home, ".fromchat/cache/fromchat")
+ } else {
+ File(System.getProperty("java.io.tmpdir"), "fromchat")
+ }
+ dir.mkdirs()
+ val dbFile = File(dir, "message_database.db")
+ val needsCreate = !dbFile.exists()
+ val driver = JdbcSqliteDriver("jdbc:sqlite:${dbFile.absolutePath}")
+ if (needsCreate) {
+ MessageDatabase.Schema.create(driver)
+ }
+ return driver
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadForeground.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadForeground.jvm.kt
new file mode 100644
index 0000000..579d578
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadForeground.jvm.kt
@@ -0,0 +1,9 @@
+package ru.fromchat.api.local.download
+
+actual object AttachmentDownloadForeground {
+ actual fun onFileDownloadStarted(storageKey: String) = Unit
+
+ actual fun onFileDownloadProgress(percent: Int, displayLabel: String?) = Unit
+
+ actual fun onFileDownloadFinished(storageKey: String) = Unit
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/DmFileDownloader.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/DmFileDownloader.jvm.kt
new file mode 100644
index 0000000..4e42293
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/DmFileDownloader.jvm.kt
@@ -0,0 +1,24 @@
+package ru.fromchat.api.local.download
+
+import java.awt.Desktop
+import java.io.File
+import java.net.URI
+
+actual suspend fun openCachedAttachmentFile(
+ cacheUri: String,
+ mimeType: String,
+ displayFilename: String?,
+): Boolean {
+ return runCatching {
+ if (!Desktop.isDesktopSupported()) return false
+ val desktop = Desktop.getDesktop()
+ if (!desktop.isSupported(Desktop.Action.OPEN)) return false
+ when {
+ cacheUri.startsWith("file:") -> desktop.open(File(URI(cacheUri)))
+ cacheUri.startsWith("http://") || cacheUri.startsWith("https://") ->
+ desktop.browse(URI(cacheUri))
+ else -> desktop.open(File(cacheUri))
+ }
+ true
+ }.getOrDefault(false)
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/EncryptedFileDownloader.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/EncryptedFileDownloader.jvm.kt
new file mode 100644
index 0000000..7cee244
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/EncryptedFileDownloader.jvm.kt
@@ -0,0 +1,15 @@
+package ru.fromchat.api.local.download
+
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.cio.CIO
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.HttpTimeoutConfig
+
+internal actual fun encryptedDownloadHttpClient(): HttpClient =
+ HttpClient(CIO) {
+ install(HttpTimeout) {
+ connectTimeoutMillis = 15_000
+ requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
+ socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
+ }
+ }
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.jvm.kt
new file mode 100644
index 0000000..4e730a8
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.jvm.kt
@@ -0,0 +1,54 @@
+package ru.fromchat.api.local.download
+
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.toComposeImageBitmap
+import java.io.File
+import org.jetbrains.skia.Bitmap
+import org.jetbrains.skia.Image
+import org.jetbrains.skia.ImageInfo
+import org.jetbrains.skia.SamplingMode
+
+actual object PlatformDecodedBitmapCache {
+ private val cache = mutableMapOf()
+
+ actual fun get(key: String): ImageBitmap? = cache[key]
+
+ actual fun put(key: String, bitmap: ImageBitmap) {
+ cache[key] = bitmap
+ }
+
+ actual fun remove(key: String) {
+ cache.remove(key)
+ }
+
+ actual fun evictPrefix(prefix: String) {
+ cache.keys.filter { it.startsWith(prefix) }.forEach { cache.remove(it) }
+ }
+}
+
+actual fun decodeLocalImageFile(absolutePath: String, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? {
+ val bytes = runCatching { File(absolutePath).readBytes() }.getOrNull() ?: return null
+ return decodeImageBytes(bytes, reqWidthPx, reqHeightPx)
+}
+
+actual fun decodeImageBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? =
+ runCatching {
+ val image = Image.makeFromEncoded(bytes) ?: return@runCatching null
+ scaleSkiaImageToFitWithin(image, reqWidthPx, reqHeightPx)
+ }.getOrNull()
+
+private fun scaleSkiaImageToFitWithin(image: Image, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap {
+ if (image.width <= reqWidthPx && image.height <= reqHeightPx) return image.toComposeImageBitmap()
+ val scale = minOf(
+ reqWidthPx.toFloat() / image.width.toFloat(),
+ reqHeightPx.toFloat() / image.height.toFloat(),
+ )
+ if (scale >= 1f) return image.toComposeImageBitmap()
+ val dstW = (image.width * scale).toInt().coerceAtLeast(1)
+ val dstH = (image.height * scale).toInt().coerceAtLeast(1)
+ val dst = Bitmap()
+ dst.allocPixels(ImageInfo.makeN32Premul(dstW, dstH))
+ val pixmap = dst.peekPixels() ?: return image.toComposeImageBitmap()
+ image.scalePixels(pixmap, SamplingMode.LINEAR, true)
+ return Image.makeFromBitmap(dst).toComposeImageBitmap()
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.jvm.kt
new file mode 100644
index 0000000..e91cf85
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.jvm.kt
@@ -0,0 +1,17 @@
+package ru.fromchat.api.local.download
+
+import java.io.ByteArrayInputStream
+import java.io.File
+import javax.imageio.ImageIO
+
+internal actual fun readLocalImageDimensions(absolutePath: String): Pair? =
+ runCatching {
+ val image = ImageIO.read(File(absolutePath)) ?: return null
+ image.width to image.height
+ }.getOrNull()
+
+internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair? =
+ runCatching {
+ val image = ImageIO.read(ByteArrayInputStream(data)) ?: return null
+ image.width to image.height
+ }.getOrNull()
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/MessageFileSave.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/MessageFileSave.jvm.kt
new file mode 100644
index 0000000..3e9a6b5
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/MessageFileSave.jvm.kt
@@ -0,0 +1,45 @@
+package ru.fromchat.api.local.download
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import java.io.File
+import javax.swing.JFileChooser
+import kotlinx.coroutines.launch
+import ru.fromchat.api.local.cache.DecryptedFileCache
+import ru.fromchat.api.local.cache.PendingFileSaveEntry
+import ru.fromchat.api.local.cache.PendingFileSaveRegistry
+
+@Composable
+actual fun rememberPlatformSaveMessageFile(
+ onComplete: (Boolean) -> Unit,
+): (SavableMessageFile) -> Unit {
+ val scope = rememberCoroutineScope()
+ return remember(onComplete) {
+ { savable: SavableMessageFile ->
+ scope.launch {
+ val chooser = JFileChooser().apply {
+ dialogType = JFileChooser.SAVE_DIALOG
+ selectedFile = File(savable.filename)
+ }
+ if (chooser.showSaveDialog(null) != JFileChooser.APPROVE_OPTION) {
+ onComplete(false)
+ return@launch
+ }
+ PendingFileSaveRegistry.schedule(
+ PendingFileSaveEntry(
+ storageKey = savable.storageKey,
+ destinationUri = chooser.selectedFile.absolutePath,
+ filename = savable.filename,
+ mimeType = savable.mimeType,
+ ),
+ )
+ DecryptedFileCache.getCached(
+ messageId = DownloadedFileRegistry.messageIdFromStorageKey(savable.storageKey) ?: -1,
+ fileIndex = DownloadedFileRegistry.fileIndexFromStorageKey(savable.storageKey) ?: 0,
+ )
+ onComplete(true)
+ }
+ }
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.jvm.kt
new file mode 100644
index 0000000..fe6e6cf
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.jvm.kt
@@ -0,0 +1,29 @@
+package ru.fromchat.api.local.download
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import java.io.File
+import javax.swing.JFileChooser
+
+@Composable
+actual fun rememberPlatformSaveMessageImage(
+ onComplete: (Boolean) -> Unit,
+): (SavableMessageImage, ByteArray) -> Unit {
+ return remember(onComplete) {
+ { savable: SavableMessageImage, bytes: ByteArray ->
+ val chooser = JFileChooser().apply {
+ dialogType = JFileChooser.SAVE_DIALOG
+ selectedFile = File(savable.filename)
+ }
+ if (chooser.showSaveDialog(null) != JFileChooser.APPROVE_OPTION) {
+ onComplete(false)
+ return@remember
+ }
+ val ok = runCatching {
+ chooser.selectedFile.writeBytes(bytes)
+ true
+ }.getOrDefault(false)
+ onComplete(ok)
+ }
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.jvm.kt
new file mode 100644
index 0000000..30a9c81
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/send/OutboxProcessor.jvm.kt
@@ -0,0 +1,13 @@
+package ru.fromchat.api.local.send
+
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+
+actual fun scheduleOutboxProcessing(instanceId: String) {
+ if (instanceId.trim().isEmpty()) return
+ MainScope().launch {
+ OutgoingMessageCoordinator.drainActiveInstanceOutbox()
+ }
+}
+
+actual fun cancelOutboxProcessing(instanceId: String) = Unit
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForeground.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForeground.jvm.kt
new file mode 100644
index 0000000..7490ba1
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/workers/AttachmentFileCopyForeground.jvm.kt
@@ -0,0 +1,6 @@
+package ru.fromchat.api.local.workers
+
+actual object AttachmentFileCopyForeground {
+ actual fun onCopyStarted(storageKey: String, displayLabel: String?) = Unit
+ actual fun onCopyFinished(storageKey: String) = Unit
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.jvm.kt
new file mode 100644
index 0000000..62f6d09
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadQueue.jvm.kt
@@ -0,0 +1,15 @@
+package ru.fromchat.api.local.workers
+
+import kotlinx.coroutines.flow.SharedFlow
+import ru.fromchat.api.local.cache.CacheContext
+import ru.fromchat.api.local.send.scheduleOutboxProcessing
+
+actual object AttachmentUploadQueue {
+ actual val progressFlow: SharedFlow = AttachmentUploadNotifier.progressFlow
+
+ actual fun enqueue(job: AttachmentUploadJob) {
+ scheduleOutboxProcessing(CacheContext.activeInstanceId.value.trim())
+ }
+
+ actual fun cancel(jobId: String) = Unit
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopAutostart.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopAutostart.kt
new file mode 100644
index 0000000..4fac874
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopAutostart.kt
@@ -0,0 +1,186 @@
+package ru.fromchat.desktop
+
+import java.io.File
+import java.nio.charset.StandardCharsets
+import java.util.prefs.Preferences
+import ru.fromchat.Logger
+
+/**
+ * Best-effort login/autostart helpers. Call from settings UI later.
+ * macOS: LaunchAgent plist; Windows: HKCU Run; Linux: XDG autostart `.desktop`.
+ */
+object DesktopAutostart {
+ private const val TAG = "DesktopAutostart"
+ private const val PREF_NODE = "ru/fromchat/desktop"
+ private const val PREF_ENABLED = "autostart_enabled"
+ private const val WINDOWS_RUN_KEY =
+ "Software\\Microsoft\\Windows\\CurrentVersion\\Run"
+ private const val APP_NAME = "FromChat"
+
+ fun isSupported(): Boolean {
+ val os = System.getProperty("os.name").orEmpty().lowercase()
+ return os.contains("mac") || os.contains("win") || os.contains("linux")
+ }
+
+ fun isEnabled(): Boolean =
+ preferences().getBoolean(PREF_ENABLED, false) && probeOsEnabled()
+
+ fun setEnabled(enabled: Boolean): Boolean {
+ val ok = runCatching {
+ when {
+ isMac() -> setMacLaunchAgent(enabled)
+ isWindows() -> setWindowsRunKey(enabled)
+ isLinux() -> setLinuxDesktopEntry(enabled)
+ else -> false
+ }
+ }.getOrElse {
+ Logger.w(TAG, "setEnabled($enabled) failed: ${it.message}", it)
+ false
+ }
+ if (ok) preferences().putBoolean(PREF_ENABLED, enabled)
+ return ok
+ }
+
+ private fun preferences(): Preferences =
+ Preferences.userRoot().node(PREF_NODE)
+
+ private fun probeOsEnabled(): Boolean = runCatching {
+ when {
+ isMac() -> launchAgentFile().isFile
+ isWindows() -> windowsRunValue() != null
+ isLinux() -> linuxDesktopFile().isFile
+ else -> false
+ }
+ }.getOrDefault(false)
+
+ private fun isMac(): Boolean =
+ System.getProperty("os.name").orEmpty().lowercase().contains("mac")
+
+ private fun isWindows(): Boolean =
+ System.getProperty("os.name").orEmpty().lowercase().contains("win")
+
+ private fun isLinux(): Boolean =
+ System.getProperty("os.name").orEmpty().lowercase().contains("linux")
+
+ private fun currentExecutableCommand(): String? {
+ val command = ProcessHandle.current().info().command().orElse(null) ?: return null
+ val args = ProcessHandle.current().info().arguments().orElse(emptyArray())
+ return buildString {
+ append(quoteIfNeeded(command))
+ for (arg in args) {
+ append(' ')
+ append(quoteIfNeeded(arg))
+ }
+ }
+ }
+
+ private fun quoteIfNeeded(value: String): String =
+ if (value.any { it.isWhitespace() || it == '"' }) {
+ "\"" + value.replace("\"", "\\\"") + "\""
+ } else {
+ value
+ }
+
+ private fun launchAgentFile(): File =
+ File(
+ System.getProperty("user.home"),
+ "Library/LaunchAgents/ru.fromchat.desktop.plist",
+ )
+
+ private fun setMacLaunchAgent(enabled: Boolean): Boolean {
+ val plist = launchAgentFile()
+ if (!enabled) {
+ runCatching {
+ ProcessBuilder("launchctl", "unload", plist.absolutePath)
+ .redirectErrorStream(true)
+ .start()
+ .waitFor()
+ }
+ return plist.delete() || !plist.exists()
+ }
+ val command = currentExecutableCommand() ?: return false
+ val programArgs = command
+ .split(Regex(" (?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)"))
+ .joinToString("\n") { " ${it.trim('"').xmlEscape()}" }
+ plist.parentFile?.mkdirs()
+ plist.writeText(
+ """
+ |
+ |
+ |
+ |
+ | Label
+ | ru.fromchat.desktop
+ | ProgramArguments
+ |
+ |$programArgs
+ |
+ | RunAtLoad
+ |
+ |
+ |
+ """.trimMargin(),
+ StandardCharsets.UTF_8,
+ )
+ ProcessBuilder("launchctl", "load", plist.absolutePath)
+ .redirectErrorStream(true)
+ .start()
+ .waitFor()
+ return plist.isFile
+ }
+
+ private fun setWindowsRunKey(enabled: Boolean): Boolean {
+ val command = currentExecutableCommand() ?: return false
+ return if (enabled) {
+ ProcessBuilder(
+ "reg", "add", "HKCU\\$WINDOWS_RUN_KEY",
+ "/v", APP_NAME, "/t", "REG_SZ", "/d", command, "/f",
+ ).redirectErrorStream(true).start().waitFor() == 0
+ } else {
+ ProcessBuilder(
+ "reg", "delete", "HKCU\\$WINDOWS_RUN_KEY",
+ "/v", APP_NAME, "/f",
+ ).redirectErrorStream(true).start().waitFor().let { it == 0 || it == 1 }
+ }
+ }
+
+ private fun windowsRunValue(): String? {
+ val process = ProcessBuilder(
+ "reg", "query", "HKCU\\$WINDOWS_RUN_KEY", "/v", APP_NAME,
+ ).redirectErrorStream(true).start()
+ val output = process.inputStream.bufferedReader().readText()
+ if (process.waitFor() != 0) return null
+ val line = output.lineSequence().firstOrNull { it.contains(APP_NAME) } ?: return null
+ return line.substringAfter("REG_SZ").trim().ifEmpty { null }
+ }
+
+ private fun linuxDesktopFile(): File =
+ File(System.getProperty("user.home"), ".config/autostart/fromchat.desktop")
+
+ private fun setLinuxDesktopEntry(enabled: Boolean): Boolean {
+ val file = linuxDesktopFile()
+ if (!enabled) return file.delete() || !file.exists()
+ val command = currentExecutableCommand() ?: return false
+ file.parentFile?.mkdirs()
+ file.writeText(
+ """
+ |[Desktop Entry]
+ |Type=Application
+ |Version=1.0
+ |Name=$APP_NAME
+ |Comment=FromChat desktop
+ |Exec=$command
+ |X-GNOME-Autostart-enabled=true
+ |Terminal=false
+ """.trimMargin(),
+ StandardCharsets.UTF_8,
+ )
+ return file.isFile
+ }
+
+ private fun String.xmlEscape(): String =
+ replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopDeepLinkBus.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopDeepLinkBus.kt
new file mode 100644
index 0000000..ef836b9
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopDeepLinkBus.kt
@@ -0,0 +1,48 @@
+package ru.fromchat.desktop
+
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import ru.fromchat.Logger
+import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
+
+/**
+ * Receives `fromchat://` URIs from the desktop process (startup args, second-instance
+ * IPC, or OS protocol launch) and fans them out to listeners (OAuth, future profile).
+ */
+object DesktopDeepLinkBus {
+ private const val TAG = "DesktopDeepLink"
+
+ private val _links = MutableSharedFlow(
+ extraBufferCapacity = 16,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+ val links: SharedFlow = _links.asSharedFlow()
+
+ private val _oauthRedirects = MutableSharedFlow(
+ extraBufferCapacity = 8,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+ val oauthRedirects: SharedFlow = _oauthRedirects.asSharedFlow()
+
+ fun handleUri(uri: String) {
+ val trimmed = uri.trim()
+ if (trimmed.isEmpty()) return
+ Logger.i(TAG, "handleUri ${shortUri(trimmed)}")
+ _links.tryEmit(trimmed)
+ if (isYandexOAuthRedirect(trimmed)) {
+ _oauthRedirects.tryEmit(trimmed)
+ }
+ }
+
+ fun isFromChatUri(uri: String): Boolean =
+ uri.trim().startsWith("fromchat://", ignoreCase = true)
+
+ private fun isYandexOAuthRedirect(uri: String): Boolean =
+ uri.startsWith(YANDEX_OAUTH_REDIRECT_URI, ignoreCase = true) ||
+ uri.startsWith("fromchat://oauth/yandex?", ignoreCase = true)
+
+ private fun shortUri(uri: String): String =
+ if (uri.length <= 120) uri else uri.take(117) + "..."
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopNotifier.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopNotifier.kt
new file mode 100644
index 0000000..ab490ff
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopNotifier.kt
@@ -0,0 +1,37 @@
+package ru.fromchat.desktop
+
+import java.awt.SystemTray
+import java.awt.TrayIcon
+import ru.fromchat.Logger
+
+/**
+ * Best-effort desktop notifications. [Main] may install a Compose Tray sink;
+ * otherwise falls back to AWT [SystemTray] when available.
+ */
+object DesktopNotifier {
+ private const val TAG = "DesktopNotifier"
+
+ @Volatile
+ var sink: ((title: String, body: String) -> Unit)? = null
+
+ fun show(title: String, body: String) {
+ val t = title.trim().ifEmpty { "FromChat" }
+ val b = body.trim()
+ sink?.invoke(t, b)?.let { return }
+ showAwtBalloon(t, b)
+ }
+
+ private fun showAwtBalloon(title: String, body: String) {
+ runCatching {
+ if (!SystemTray.isSupported()) {
+ Logger.d(TAG, "SystemTray unsupported; drop notification")
+ return
+ }
+ val tray = SystemTray.getSystemTray()
+ val icon = tray.trayIcons.firstOrNull() ?: return
+ icon.displayMessage(title, body, TrayIcon.MessageType.INFO)
+ }.onFailure {
+ Logger.w(TAG, "AWT balloon failed: ${it.message}", it)
+ }
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/legal/LegalMarkdown.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/legal/LegalMarkdown.jvm.kt
new file mode 100644
index 0000000..ea737d3
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/legal/LegalMarkdown.jvm.kt
@@ -0,0 +1,18 @@
+package ru.fromchat.legal
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+
+@Composable
+actual fun Markdown(
+ content: String,
+ modifier: Modifier,
+) {
+ val uriHandler = LocalUriHandler.current
+ MarkdownPlain(
+ content = content,
+ modifier = modifier,
+ onLinkClick = { uriHandler.openUri(it) },
+ )
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/logging/GzipCompress.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/logging/GzipCompress.jvm.kt
new file mode 100644
index 0000000..40f1f06
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/logging/GzipCompress.jvm.kt
@@ -0,0 +1,10 @@
+package ru.fromchat.logging
+
+import java.io.ByteArrayOutputStream
+import java.util.zip.GZIPOutputStream
+
+internal actual fun gzipCompress(input: ByteArray): ByteArray {
+ val output = ByteArrayOutputStream(input.size)
+ GZIPOutputStream(output).use { gzip -> gzip.write(input) }
+ return output.toByteArray()
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/logging/LogFileOps.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/logging/LogFileOps.jvm.kt
new file mode 100644
index 0000000..f1148d1
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/logging/LogFileOps.jvm.kt
@@ -0,0 +1,96 @@
+package ru.fromchat.logging
+
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileInputStream
+import java.util.zip.GZIPInputStream
+import java.util.zip.GZIPOutputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+internal actual object LogFileOps {
+ actual fun readText(path: String): String {
+ val file = File(path)
+ if (!file.isFile) return ""
+ return runCatching { file.readText() }.getOrDefault("")
+ }
+
+ actual fun readBytes(path: String): ByteArray {
+ val file = File(path)
+ if (!file.isFile) return ByteArray(0)
+ return runCatching { file.readBytes() }.getOrDefault(ByteArray(0))
+ }
+
+ actual suspend fun gzipFile(sourcePath: String, destinationPath: String) = withContext(Dispatchers.IO) {
+ val source = File(sourcePath)
+ if (!source.isFile) return@withContext
+ val dest = File(destinationPath)
+ dest.parentFile?.mkdirs()
+ FileInputStream(source).use { input ->
+ GZIPOutputStream(dest.outputStream()).use { gzip ->
+ input.copyTo(gzip)
+ }
+ }
+ source.delete()
+ }
+
+ actual suspend fun readGzipText(path: String, onProgress: (Float) -> Unit): String =
+ withContext(Dispatchers.IO) {
+ gunzipToByteArray(path, onProgress).decodeToString()
+ }
+
+ actual suspend fun gunzipToFile(
+ sourcePath: String,
+ destinationPath: String,
+ onProgress: (Float) -> Unit,
+ ) = withContext(Dispatchers.IO) {
+ val bytes = gunzipToByteArray(sourcePath, onProgress)
+ val dest = File(destinationPath)
+ dest.parentFile?.mkdirs()
+ dest.writeBytes(bytes)
+ }
+
+ actual suspend fun zipFiles(
+ entries: List>,
+ destinationPath: String,
+ ) = withContext(Dispatchers.IO) {
+ if (entries.isEmpty()) return@withContext
+ val dest = File(destinationPath)
+ dest.parentFile?.mkdirs()
+ ZipOutputStream(dest.outputStream()).use { zip ->
+ entries.forEach { (entryName, sourcePath) ->
+ val source = File(sourcePath)
+ if (!source.isFile) return@forEach
+ zip.putNextEntry(ZipEntry(entryName))
+ FileInputStream(source).use { input -> input.copyTo(zip) }
+ zip.closeEntry()
+ }
+ }
+ }
+
+ private fun gunzipToByteArray(path: String, onProgress: (Float) -> Unit): ByteArray {
+ val file = File(path)
+ if (!file.isFile) {
+ onProgress(1f)
+ return ByteArray(0)
+ }
+ val total = file.length().coerceAtLeast(1L)
+ val output = ByteArrayOutputStream()
+ FileInputStream(file).use { input ->
+ GZIPInputStream(input).use { gzip ->
+ val buffer = ByteArray(8_192)
+ var read: Int
+ var consumed = 0L
+ while (gzip.read(buffer).also { read = it } != -1) {
+ output.write(buffer, 0, read)
+ consumed = (consumed + read).coerceAtMost(total)
+ onProgress((consumed.toFloat() / total).coerceIn(0f, 1f))
+ }
+ }
+ }
+ onProgress(1f)
+ return output.toByteArray()
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/logging/LogShare.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/logging/LogShare.jvm.kt
new file mode 100644
index 0000000..c148cce
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/logging/LogShare.jvm.kt
@@ -0,0 +1,23 @@
+package ru.fromchat.logging
+
+import java.awt.Desktop
+import java.awt.Toolkit
+import java.awt.datatransfer.StringSelection
+import java.io.File
+
+actual object LogShare {
+ actual fun shareText(title: String, text: String) {
+ Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(text), null)
+ println("LogShare: copied text to clipboard ($title)")
+ }
+
+ actual fun shareFile(title: String, filePath: String, mimeType: String) {
+ runCatching {
+ if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) {
+ Desktop.getDesktop().open(File(filePath))
+ } else {
+ println("LogShare: file at $filePath ($title, $mimeType)")
+ }
+ }
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.jvm.kt
new file mode 100644
index 0000000..8811b42
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.jvm.kt
@@ -0,0 +1,5 @@
+package ru.fromchat.notifications
+
+actual object ChatNotificationDismissals {
+ actual fun dismissAllMessageNotifications() = Unit
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/Theme.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/Theme.jvm.kt
new file mode 100644
index 0000000..9da7155
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/Theme.jvm.kt
@@ -0,0 +1,13 @@
+package ru.fromchat.ui
+
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+@Composable
+actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
+ if (darkTheme) darkColorScheme() else lightColorScheme()
+
+@Composable
+actual fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color) = Unit
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.jvm.kt
new file mode 100644
index 0000000..7cc7df8
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.jvm.kt
@@ -0,0 +1,192 @@
+package ru.fromchat.ui.auth.captcha
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.awt.SwingPanel
+import javafx.application.Platform
+import javafx.concurrent.Worker
+import javafx.embed.swing.JFXPanel
+import javafx.scene.Scene
+import javafx.scene.web.WebEngine
+import javafx.scene.web.WebView
+import netscape.javascript.JSObject
+import ru.fromchat.Logger
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.swing.SwingUtilities
+
+private const val SMARTCAPTCHA_WEBVIEW_BASE = "https://smartcaptcha.cloud.yandex.ru/webview"
+
+/**
+ * JVM SmartCaptcha via OpenJFX [WebView] embedded in a Compose [SwingPanel].
+ * Bridges `window.NativeClient` the same way as Android's JavascriptInterface.
+ */
+@Composable
+actual fun SmartCaptchaWebView(
+ sitekey: String,
+ languageTag: String,
+ modifier: Modifier,
+ onToken: (String) -> Unit,
+ onReady: () -> Unit,
+ onChallengeVisible: () -> Unit,
+ onChallengeHidden: () -> Unit,
+ onError: (String) -> Unit,
+) {
+ val onTokenState = rememberUpdatedState(onToken)
+ val onReadyState = rememberUpdatedState(onReady)
+ val onChallengeVisibleState = rememberUpdatedState(onChallengeVisible)
+ val onChallengeHiddenState = rememberUpdatedState(onChallengeHidden)
+ val onErrorState = rememberUpdatedState(onError)
+ val surfaceContainer = MaterialTheme.colorScheme.surfaceContainer
+ val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
+ val captchaUrl = remember(sitekey, lang) {
+ "$SMARTCAPTCHA_WEBVIEW_BASE?sitekey=${sitekey.trim()}&hl=$lang"
+ }
+ val instanceId = remember { Integer.toHexString(System.identityHashCode(Any())) }
+ val bridge = remember {
+ SmartCaptchaJsBridge(
+ instanceId = instanceId,
+ deliverToken = { token -> onTokenState.value(token) },
+ deliverChallengeVisible = { onChallengeVisibleState.value() },
+ deliverChallengeHidden = { onChallengeHiddenState.value() },
+ deliverError = { message -> onErrorState.value(message) },
+ )
+ }
+
+ DisposableEffect(instanceId) {
+ Logger.i(
+ SmartCaptchaLog.TAG,
+ "JavaFX WebView compose enter id=$instanceId " +
+ "sitekey=${SmartCaptchaLog.redactKey(sitekey)} lang=$lang " +
+ "url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
+ )
+ ensureJavaFxStarted()
+ onDispose {
+ Logger.i(SmartCaptchaLog.TAG, "JavaFX WebView compose dispose id=$instanceId")
+ }
+ }
+
+ SwingPanel(
+ factory = {
+ JFXPanel().also { panel ->
+ panel.background = java.awt.Color(
+ surfaceContainer.red,
+ surfaceContainer.green,
+ surfaceContainer.blue,
+ surfaceContainer.alpha,
+ )
+ Platform.runLater {
+ runCatching {
+ val webView = WebView()
+ val engine = webView.engine
+ engine.isJavaScriptEnabled = true
+ engine.loadWorker.stateProperty().addListener { _, _, newState ->
+ when (newState) {
+ Worker.State.SUCCEEDED -> {
+ injectNativeClient(engine, bridge)
+ SwingUtilities.invokeLater { onReadyState.value() }
+ }
+ Worker.State.FAILED -> {
+ val message = engine.loadWorker.exception?.message.orEmpty()
+ Logger.w(
+ SmartCaptchaLog.TAG,
+ "load FAILED id=$instanceId message=$message",
+ )
+ SwingUtilities.invokeLater { onErrorState.value(message) }
+ }
+ else -> Unit
+ }
+ }
+ engine.load(captchaUrl)
+ panel.scene = Scene(webView)
+ Logger.i(
+ SmartCaptchaLog.TAG,
+ "loadUrl id=$instanceId url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
+ )
+ }.onFailure { error ->
+ Logger.e(
+ SmartCaptchaLog.TAG,
+ "JavaFX WebView setup failed id=$instanceId: ${error.message}",
+ error,
+ )
+ SwingUtilities.invokeLater {
+ onErrorState.value(error.message.orEmpty())
+ }
+ }
+ }
+ }
+ },
+ modifier = modifier.fillMaxSize(),
+ )
+}
+
+/** Public methods are invoked from JavaScript via `window.NativeClient`. */
+class SmartCaptchaJsBridge(
+ private val instanceId: String,
+ private val deliverToken: (String) -> Unit,
+ private val deliverChallengeVisible: () -> Unit,
+ private val deliverChallengeHidden: () -> Unit,
+ private val deliverError: (String) -> Unit,
+) {
+ fun onGetToken(token: String) {
+ val cleaned = token.trim()
+ Logger.i(
+ SmartCaptchaLog.TAG,
+ "JS onGetToken id=$instanceId ${SmartCaptchaLog.redactToken(cleaned)}",
+ )
+ SwingUtilities.invokeLater {
+ if (cleaned.isNotEmpty()) {
+ deliverToken(cleaned)
+ } else {
+ Logger.w(SmartCaptchaLog.TAG, "JS onGetToken empty id=$instanceId")
+ deliverError("")
+ }
+ }
+ }
+
+ fun onChallengeVisible() {
+ Logger.i(SmartCaptchaLog.TAG, "JS onChallengeVisible id=$instanceId")
+ SwingUtilities.invokeLater(deliverChallengeVisible)
+ }
+
+ fun onChallengeHidden() {
+ Logger.i(SmartCaptchaLog.TAG, "JS onChallengeHidden id=$instanceId")
+ SwingUtilities.invokeLater(deliverChallengeHidden)
+ }
+}
+
+private fun injectNativeClient(engine: WebEngine, bridge: SmartCaptchaJsBridge) {
+ runCatching {
+ val window = engine.executeScript("window") as JSObject
+ window.setMember("NativeClient", bridge)
+ }.onFailure {
+ Logger.w(SmartCaptchaLog.TAG, "inject NativeClient failed: ${it.message}", it)
+ }
+}
+
+private val javaFxStarted = AtomicBoolean(false)
+
+private fun ensureJavaFxStarted() {
+ if (javaFxStarted.get()) return
+ synchronized(javaFxStarted) {
+ if (javaFxStarted.get()) return
+ runCatching {
+ Platform.setImplicitExit(false)
+ val latch = CountDownLatch(1)
+ Platform.startup { latch.countDown() }
+ if (!latch.await(5, TimeUnit.SECONDS)) {
+ Logger.w(SmartCaptchaLog.TAG, "JavaFX Platform.startup timed out")
+ }
+ }.onFailure { error ->
+ // Toolkit may already be running via JFXPanel.
+ Logger.d(SmartCaptchaLog.TAG, "JavaFX startup: ${error.message}")
+ }
+ javaFxStarted.set(true)
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.jvm.kt
new file mode 100644
index 0000000..2f5e81a
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.jvm.kt
@@ -0,0 +1,112 @@
+package ru.fromchat.ui.auth.yandex
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import ru.fromchat.Logger
+import ru.fromchat.Res
+import ru.fromchat.auth.yandex.extractOAuthCode
+import ru.fromchat.auth_yandex_browser_body
+import ru.fromchat.cancel
+import ru.fromchat.desktop.DesktopDeepLinkBus
+import ru.fromchat.ui.components.ActionButton
+import ru.fromchat.ui.components.Text
+import java.awt.Desktop
+import java.net.URI
+
+private const val LOG_TAG = "YandexOAuthWV"
+
+@Composable
+actual fun YandexOAuthWebView(
+ authorizeUrl: String,
+ languageTag: String,
+ darkTheme: Boolean,
+ fallbackColor: Color,
+ clearCookies: Boolean,
+ onPageBackgroundColor: (Color) -> Unit,
+ onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
+ onCode: (String) -> Unit,
+ onError: (String) -> Unit,
+ onCancel: () -> Unit,
+) {
+ val onCodeState = rememberUpdatedState(onCode)
+ val onErrorState = rememberUpdatedState(onError)
+ val onCancelState = rememberUpdatedState(onCancel)
+ val onPageBackgroundColorState = rememberUpdatedState(onPageBackgroundColor)
+ val onHistoryBackAvailabilityChangedState = rememberUpdatedState(onHistoryBackAvailabilityChanged)
+
+ SideEffect {
+ onPageBackgroundColorState.value(fallbackColor)
+ onHistoryBackAvailabilityChangedState.value(false)
+ }
+
+ LaunchedEffect(authorizeUrl) {
+ Logger.i(LOG_TAG, "open system browser for Yandex OAuth")
+ openInSystemBrowser(authorizeUrl).onFailure { error ->
+ Logger.w(LOG_TAG, "browse failed: ${error.message}", error)
+ onErrorState.value(error.message.orEmpty())
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ DesktopDeepLinkBus.oauthRedirects.collect { redirectUrl ->
+ Logger.i(LOG_TAG, "oauth redirect received")
+ val code = extractOAuthCode(redirectUrl)
+ if (code != null) {
+ onCodeState.value(code)
+ } else {
+ onErrorState.value("")
+ }
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(Res.string.auth_yandex_browser_body),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ ActionButton(
+ onClick = { onCancelState.value() },
+ modifier = Modifier.fillMaxWidth(),
+ outlined = true,
+ ) {
+ Text(stringResource(Res.string.cancel))
+ }
+ }
+}
+
+private fun openInSystemBrowser(url: String): Result = runCatching {
+ if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
+ Desktop.getDesktop().browse(URI(url))
+ return@runCatching
+ }
+ val os = System.getProperty("os.name").orEmpty().lowercase()
+ when {
+ os.contains("mac") -> Runtime.getRuntime().exec(arrayOf("open", url))
+ os.contains("win") -> Runtime.getRuntime().exec(
+ arrayOf("rundll32", "url.dll,FileProtocolHandler", url),
+ )
+ else -> Runtime.getRuntime().exec(arrayOf("xdg-open", url))
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.jvm.kt
new file mode 100644
index 0000000..3d67fae
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.jvm.kt
@@ -0,0 +1,246 @@
+package ru.fromchat.ui.calls
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.awt.SwingPanel
+import javafx.application.Platform
+import javafx.concurrent.Worker
+import javafx.embed.swing.JFXPanel
+import javafx.scene.Scene
+import javafx.scene.web.WebEngine
+import javafx.scene.web.WebView
+import netscape.javascript.JSObject
+import ru.fromchat.Logger
+import ru.fromchat.api.calls.CallStore
+import ru.fromchat.api.calls.LiveKitConnectSession
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+import javax.swing.SwingUtilities
+
+private const val TAG = "CallMediaLayer"
+
+@Composable
+actual fun CallMediaLayer(
+ connect: LiveKitConnectSession?,
+ showDialingPlaceholder: Boolean,
+ showInCallControls: Boolean,
+ modifier: Modifier,
+) {
+ when {
+ connect == null && showDialingPlaceholder -> {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceContainerHighest),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
+ }
+ }
+ connect != null -> {
+ key(connect.roomName) {
+ LiveKitJavaFxCall(
+ session = connect,
+ showInCallControls = showInCallControls,
+ modifier = modifier,
+ )
+ }
+ }
+ else -> Box(modifier = modifier.fillMaxSize())
+ }
+}
+
+@Composable
+private fun LiveKitJavaFxCall(
+ session: LiveKitConnectSession,
+ showInCallControls: Boolean,
+ modifier: Modifier,
+) {
+ var micOn by remember(session.roomName) { mutableStateOf(true) }
+ var camOn by remember(session.roomName) { mutableStateOf(true) }
+ val engineRef = remember { AtomicReference(null) }
+ val sessionState = rememberUpdatedState(session)
+ val surface = MaterialTheme.colorScheme.surfaceContainerLowest
+ val bridge = remember {
+ LiveKitCallJsBridge(
+ deliverConnected = {
+ Logger.i(TAG, "LiveKit JS connected room=${sessionState.value.roomName}")
+ },
+ deliverFailed = { message ->
+ Logger.e(TAG, "LiveKit JS connect failed: $message")
+ CallStore.onLiveKitConnectFailed(sessionState.value, message)
+ },
+ )
+ }
+
+ DisposableEffect(session.roomName) {
+ ensureJavaFxStarted()
+ onDispose {
+ val engine = engineRef.get()
+ if (engine != null) {
+ Platform.runLater {
+ runCatching { engine.executeScript(LiveKitCallWebPage.disconnectScript()) }
+ }
+ }
+ engineRef.set(null)
+ }
+ }
+
+ Box(modifier = modifier.fillMaxSize()) {
+ SwingPanel(
+ factory = {
+ JFXPanel().also { panel ->
+ panel.background = java.awt.Color(
+ surface.red,
+ surface.green,
+ surface.blue,
+ surface.alpha,
+ )
+ Platform.runLater {
+ runCatching {
+ val webView = WebView()
+ val engine = webView.engine
+ engine.isJavaScriptEnabled = true
+ engineRef.set(engine)
+ engine.loadWorker.stateProperty().addListener { _, _, newState ->
+ when (newState) {
+ Worker.State.SUCCEEDED -> {
+ injectNativeBridge(engine, bridge)
+ runCatching {
+ engine.executeScript(
+ LiveKitCallWebPage.connectScript(
+ sessionState.value.serverUrl,
+ sessionState.value.token,
+ ),
+ )
+ }.onFailure {
+ Logger.e(TAG, "connect script failed: ${it.message}", it)
+ SwingUtilities.invokeLater {
+ CallStore.onLiveKitConnectFailed(
+ sessionState.value,
+ it.message,
+ )
+ }
+ }
+ }
+ Worker.State.FAILED -> {
+ val message = engine.loadWorker.exception?.message.orEmpty()
+ Logger.w(TAG, "WebView load FAILED: $message")
+ SwingUtilities.invokeLater {
+ CallStore.onLiveKitConnectFailed(
+ sessionState.value,
+ message.ifBlank { "WebView load failed" },
+ )
+ }
+ }
+ else -> Unit
+ }
+ }
+ engine.loadContent(LiveKitCallWebPage.html)
+ panel.scene = Scene(webView)
+ }.onFailure { error ->
+ Logger.e(TAG, "JavaFX WebView setup failed: ${error.message}", error)
+ SwingUtilities.invokeLater {
+ CallStore.onLiveKitConnectFailed(
+ sessionState.value,
+ error.message,
+ )
+ }
+ }
+ }
+ }
+ },
+ modifier = Modifier.fillMaxSize(),
+ )
+
+ if (showInCallControls) {
+ WebCallInCallControls(
+ micOn = micOn,
+ camOn = camOn,
+ onMicToggle = {
+ val next = !micOn
+ micOn = next
+ engineRef.get()?.let { engine ->
+ Platform.runLater {
+ runCatching {
+ engine.executeScript(LiveKitCallWebPage.setMicScript(next))
+ }
+ }
+ }
+ },
+ onCamToggle = {
+ val next = !camOn
+ camOn = next
+ engineRef.get()?.let { engine ->
+ Platform.runLater {
+ runCatching {
+ engine.executeScript(LiveKitCallWebPage.setCamScript(next))
+ }
+ }
+ }
+ },
+ onEndCall = { CallStore.endCall() },
+ modifier = Modifier.align(Alignment.BottomCenter),
+ )
+ }
+ }
+}
+
+/** Methods invoked from JavaScript via `window.FromChatNative`. */
+class LiveKitCallJsBridge(
+ private val deliverConnected: () -> Unit,
+ private val deliverFailed: (String) -> Unit,
+) {
+ fun onConnected(message: String?) {
+ SwingUtilities.invokeLater(deliverConnected)
+ }
+
+ fun onConnectFailed(message: String?) {
+ val msg = message.orEmpty()
+ SwingUtilities.invokeLater { deliverFailed(msg) }
+ }
+}
+
+private fun injectNativeBridge(engine: WebEngine, bridge: LiveKitCallJsBridge) {
+ runCatching {
+ val window = engine.executeScript("window") as JSObject
+ window.setMember("FromChatNative", bridge)
+ }.onFailure {
+ Logger.w(TAG, "inject FromChatNative failed: ${it.message}", it)
+ }
+}
+
+private val javaFxStarted = AtomicBoolean(false)
+
+private fun ensureJavaFxStarted() {
+ if (javaFxStarted.get()) return
+ synchronized(javaFxStarted) {
+ if (javaFxStarted.get()) return
+ runCatching {
+ Platform.setImplicitExit(false)
+ val latch = CountDownLatch(1)
+ Platform.startup { latch.countDown() }
+ if (!latch.await(5, TimeUnit.SECONDS)) {
+ Logger.w(TAG, "JavaFX Platform.startup timed out")
+ }
+ }.onFailure { error ->
+ Logger.d(TAG, "JavaFX startup: ${error.message}")
+ }
+ javaFxStarted.set(true)
+ }
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.jvm.kt
new file mode 100644
index 0000000..ef19904
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/calls/InlineCallChrome.jvm.kt
@@ -0,0 +1,7 @@
+package ru.fromchat.ui.calls
+
+/**
+ * When true, [CallMediaLayer] owns in-call mic/camera/end chrome (Android native +
+ * WebView-based iOS/desktop). Call overlays still pass [showInCallControls].
+ */
+actual val UseInlineInCallChrome: Boolean = true
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/calls/NotifyIncomingCall.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/calls/NotifyIncomingCall.jvm.kt
new file mode 100644
index 0000000..83ad358
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/calls/NotifyIncomingCall.jvm.kt
@@ -0,0 +1,7 @@
+package ru.fromchat.ui.calls
+
+import ru.fromchat.desktop.DesktopNotifier
+
+internal actual fun notifyIncomingCall(title: String, body: String) {
+ DesktopNotifier.show(title, body)
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentFileDownload.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentFileDownload.jvm.kt
new file mode 100644
index 0000000..d85e79b
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentFileDownload.jvm.kt
@@ -0,0 +1,27 @@
+package ru.fromchat.ui.chat.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import java.io.File
+import javax.swing.JFileChooser
+
+@Composable
+actual fun rememberCreateDownloadDestinationLauncher(
+ onDestination: (String?) -> Unit,
+): (filename: String, mimeType: String) -> Unit {
+ return remember(onDestination) {
+ { filename, _ ->
+ val chooser = JFileChooser().apply {
+ dialogType = JFileChooser.SAVE_DIALOG
+ selectedFile = File(filename)
+ }
+ if (chooser.showSaveDialog(null) == JFileChooser.APPROVE_OPTION) {
+ onDestination(chooser.selectedFile.absolutePath)
+ } else {
+ onDestination(null)
+ }
+ }
+ }
+}
+
+actual suspend fun persistExportUriPermissionIfNeeded(exportUri: String) = Unit
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentOpenFeedback.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentOpenFeedback.jvm.kt
new file mode 100644
index 0000000..ece9f6b
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentOpenFeedback.jvm.kt
@@ -0,0 +1,5 @@
+package ru.fromchat.ui.chat.utils
+
+internal actual fun showAttachmentOpenFailed(message: String) {
+ System.err.println("Attachment open failed: $message")
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.jvm.kt
new file mode 100644
index 0000000..883d71c
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentPickers.jvm.kt
@@ -0,0 +1,74 @@
+package ru.fromchat.ui.chat.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import java.awt.FileDialog
+import java.awt.Frame
+import java.io.File
+import java.net.URI
+import javax.imageio.ImageIO
+import javax.swing.JFileChooser
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+actual fun getFilenameFromUri(uri: String): String {
+ val path = when {
+ uri.startsWith("file:") -> runCatching { URI(uri).path }.getOrNull() ?: uri
+ else -> uri
+ }
+ return path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
+}
+
+@Composable
+actual fun rememberImagePicker(onResult: (List) -> Unit): () -> Unit {
+ return remember(onResult) {
+ {
+ val dialog = FileDialog(null as Frame?, "Select images", FileDialog.LOAD).apply {
+ isMultipleMode = true
+ setFilenameFilter { _, name ->
+ val lower = name.lowercase()
+ lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") ||
+ lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".bmp")
+ }
+ }
+ dialog.isVisible = true
+ val dir = dialog.directory
+ val files = dialog.files
+ if (dir != null && files != null && files.isNotEmpty()) {
+ onResult(files.map { File(dir, it.name).absolutePath })
+ }
+ }
+ }
+}
+
+@Composable
+actual fun rememberFilePicker(onResult: (List) -> Unit): () -> Unit {
+ return remember(onResult) {
+ {
+ val chooser = JFileChooser().apply {
+ isMultiSelectionEnabled = true
+ fileSelectionMode = JFileChooser.FILES_ONLY
+ }
+ if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
+ onResult(chooser.selectedFiles.map { it.absolutePath })
+ }
+ }
+ }
+}
+
+actual suspend fun getImageAspectRatio(uri: String): Float? {
+ val (w, h) = getImageDimensions(uri) ?: return null
+ if (h == 0) return null
+ return w.toFloat() / h.toFloat()
+}
+
+actual suspend fun getImageDimensions(uri: String): Pair? = withContext(Dispatchers.IO) {
+ val path = when {
+ uri.startsWith("file:") -> runCatching { URI(uri).path }.getOrNull() ?: return@withContext null
+ else -> uri
+ }
+ runCatching {
+ val image = ImageIO.read(File(path)) ?: return@runCatching null
+ image.width to image.height
+ }.getOrNull()
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/components/BackHandler.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/components/BackHandler.jvm.kt
new file mode 100644
index 0000000..8eb4ec8
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/components/BackHandler.jvm.kt
@@ -0,0 +1,11 @@
+package ru.fromchat.ui.components
+
+import androidx.compose.runtime.Composable
+
+@Composable
+actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
+ // Desktop: back is handled by navigation / Escape bindings.
+}
+
+@Composable
+actual fun rememberHapticFeedbackInternal(): (Int) -> Unit = { _ -> }
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/components/PredictiveBackHandler.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/components/PredictiveBackHandler.jvm.kt
new file mode 100644
index 0000000..634cfbc
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/components/PredictiveBackHandler.jvm.kt
@@ -0,0 +1,13 @@
+package ru.fromchat.ui.components
+
+import androidx.compose.runtime.Composable
+
+@Composable
+actual fun PredictiveBackHandler(
+ enabled: Boolean,
+ onProgress: (Float) -> Unit,
+ onCommit: () -> Unit,
+ onCancel: () -> Unit,
+) {
+ onProgress(0f)
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.jvm.kt
new file mode 100644
index 0000000..41dadc4
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.jvm.kt
@@ -0,0 +1,7 @@
+package ru.fromchat.ui.main.settings
+
+actual fun openAppNotificationSettings(): Boolean = false
+
+actual fun areAppNotificationsEnabled(): Boolean = true
+
+actual fun arePushNotificationsSupported(): Boolean = false
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.jvm.kt
new file mode 100644
index 0000000..756fac6
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.jvm.kt
@@ -0,0 +1,3 @@
+package ru.fromchat.ui.profile
+
+actual fun showProfileLoadErrorMessage(message: String) = Unit
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/utils/NetworkConnectivity.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/utils/NetworkConnectivity.jvm.kt
new file mode 100644
index 0000000..38bbbef
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/utils/NetworkConnectivity.jvm.kt
@@ -0,0 +1,12 @@
+package ru.fromchat.utils
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+actual object NetworkConnectivity {
+ private val _isOnline = MutableStateFlow(true)
+ actual val isOnline: StateFlow = _isOnline.asStateFlow()
+
+ actual fun ensureStarted() = Unit
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f9e4c47..dbabf40 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -5,13 +5,13 @@ androidx-appcompat = "1.7.1"
androidx-core-ktx = "1.19.0"
androidx-exifinterface = "1.4.2"
coilCompose = "3.5.0"
-compose-multiplatform = "1.11.1"
+compose-multiplatform = "1.12.0-beta02"
#noinspection NewerVersionAvailable
constraintlayout = "0.8.0-shaded"
coreSplashscreen = "1.2.0"
firebaseMessaging = "25.1.1"
googleServices = "4.5.0"
-haze = "1.7.2"
+haze = "2.0.0-alpha03"
kotlin = "2.4.10"
adaptiveAndroid = "1.2.0"
biometric = "1.4.0-alpha07"
@@ -35,7 +35,7 @@ lifecycleRuntimeKtx = "2.11.0"
composeBom = "2026.06.01"
composeMaterialIconsExtended = "1.7.3"
composeMaterial3 = "1.12.0-alpha03"
-composeComponents = "1.11.1"
+composeComponents = "1.12.0-beta02"
playServicesBase = "18.10.0"
tweetnaclJava = "1.1.3"
androidxWork = "2.11.2"
@@ -47,6 +47,7 @@ livekitAndroidComposeComponents = "2.4.0"
markdownRendererM3 = "0.43.0"
bouncycastle = "1.85"
webkit = "1.16.0"
+openjfx = "21.0.6"
[libraries]
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
@@ -62,8 +63,10 @@ constraintlayout = { module = "tech.annexflow.compose:constraintlayout-compose-m
firebase-messaging = { module = "com.google.firebase:firebase-messaging", version.ref = "firebaseMessaging" }
google-services = { module = "com.google.gms:google-services", version.ref = "googleServices" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
-haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
+haze-blur = { module = "dev.chrisbanes.haze:haze-blur", version.ref = "haze" }
+haze-blur-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }
jetbrains-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
+kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutinesCore" }
jetbrains-kotlinx-io-bytestring = { module = "org.jetbrains.kotlinx:kotlinx-io-bytestring", version.ref = "kotlinxIoBytestring" }
androidx-adaptive-android = { group = "androidx.compose.material3.adaptive", name = "adaptive-android", version.ref = "adaptiveAndroid" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }
@@ -88,6 +91,8 @@ ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.re
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
+ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
+ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }
slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
@@ -110,10 +115,14 @@ cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", ver
cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography-kotlin" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidxWork" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
+openjfx-controls = { module = "org.openjfx:javafx-controls", version.ref = "openjfx" }
+openjfx-web = { module = "org.openjfx:javafx-web", version.ref = "openjfx" }
+openjfx-swing = { module = "org.openjfx:javafx-swing", version.ref = "openjfx" }
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
+sqldelight-driver-sqlite = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
livekit-android = { module = "io.livekit:livekit-android", version.ref = "livekitAndroid" }
livekit-android-compose-components = { module = "io.livekit:livekit-android-compose-components", version.ref = "livekitAndroidComposeComponents" }
markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRendererM3" }
diff --git a/release.sh b/release.sh
index fcd0e9a..8b5f8b5 100755
--- a/release.sh
+++ b/release.sh
@@ -169,7 +169,7 @@ build_ios() {
substep "Xcode Archiving..."
rm -rf "$BUILD_DIR/ios" && mkdir -p "$BUILD_DIR/ios"
if ! xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
- -scheme iOS \
+ -scheme iosApp \
-configuration Release \
-sdk iphoneos \
-destination 'generic/platform=iOS' \
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 0f9efe5..9034d1c 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -38,5 +38,5 @@ dependencyResolutionManagement {
}
}
-include(":app:shared", ":utils:shared", ":app:android")
+include(":app:shared", ":utils:shared", ":app:android", ":app:desktop")
\ No newline at end of file
diff --git a/utils/shared/build.gradle.kts b/utils/shared/build.gradle.kts
index 83a7f43..d31c4d8 100644
--- a/utils/shared/build.gradle.kts
+++ b/utils/shared/build.gradle.kts
@@ -16,6 +16,8 @@ kotlin {
minSdk = 24
}
+ jvm()
+
listOf(
iosArm64(),
iosSimulatorArm64()
@@ -62,6 +64,13 @@ kotlin {
implementation(libs.multiplatform.settings.coroutines)
implementation(libs.multiplatform.settings.serialization)
}
+
+ jvmMain.dependencies {
+ implementation(libs.kotlinx.datetime)
+ implementation(libs.multiplatform.settings)
+ implementation(libs.multiplatform.settings.coroutines)
+ implementation(libs.multiplatform.settings.serialization)
+ }
}
}
diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Components.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Components.kt
index bc7bab0..46c4dc3 100644
--- a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Components.kt
+++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Components.kt
@@ -21,8 +21,8 @@ data class NavigationItem(
@Composable
fun SimpleNavigationBar(
selectedItem: Int,
- vararg items: NavigationItem,
modifier: Modifier = Modifier,
+ vararg items: NavigationItem,
) {
NavigationBar(modifier) {
items.forEachIndexed { index, item ->
@@ -48,8 +48,8 @@ fun SimpleNavigationBar(
@Composable
fun SimpleNavigationRail(
selectedItem: Int,
- vararg items: NavigationItem,
modifier: Modifier = Modifier,
+ vararg items: NavigationItem,
) {
NavigationRail(
modifier,
@@ -73,4 +73,4 @@ fun SimpleNavigationRail(
)
}
}
-}
\ No newline at end of file
+}
diff --git a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/IosHmac.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/IosHmac.kt
index f22dfef..4346c6d 100644
--- a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/IosHmac.kt
+++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/IosHmac.kt
@@ -8,7 +8,7 @@ import platform.CoreCrypto.CCHmac
import platform.CoreCrypto.kCCHmacAlgSHA256
@OptIn(ExperimentalForeignApi::class)
-internal fun iosHmacSha256(key: ByteArray, data: ByteArray): ByteArray {
+fun iosHmacSha256(key: ByteArray, data: ByteArray): ByteArray {
val mac = ByteArray(32)
key.usePinned { keyPinned ->
diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/Adaptive.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/Adaptive.jvm.kt
new file mode 100644
index 0000000..370db58
--- /dev/null
+++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/Adaptive.jvm.kt
@@ -0,0 +1,9 @@
+package com.pr0gramm3r101.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.platform.LocalWindowInfo
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+actual inline fun currentWindowSize() = LocalWindowInfo.current.containerSize
diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/ClipboardSupport.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/ClipboardSupport.jvm.kt
new file mode 100644
index 0000000..6583b45
--- /dev/null
+++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/ClipboardSupport.jvm.kt
@@ -0,0 +1,27 @@
+package com.pr0gramm3r101.utils
+
+import androidx.compose.ui.platform.Clipboard
+import java.awt.Toolkit
+import java.awt.datatransfer.DataFlavor
+import java.awt.datatransfer.StringSelection
+
+actual fun Clipboard.toSupport(): SupportClipboardManager {
+ return object : SupportClipboardManager {
+ private var listener: ((String) -> Unit)? = null
+
+ override suspend fun setText(string: String) {
+ Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(string), null)
+ listener?.invoke(string)
+ }
+
+ override suspend fun getText(): String? {
+ val contents = Toolkit.getDefaultToolkit().systemClipboard.getContents(null) ?: return null
+ if (!contents.isDataFlavorSupported(DataFlavor.stringFlavor)) return null
+ return contents.getTransferData(DataFlavor.stringFlavor) as? String
+ }
+
+ override fun setTextListener(listener: (String) -> Unit) {
+ this.listener = listener
+ }
+ }
+}
diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/Compose.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/Compose.jvm.kt
new file mode 100644
index 0000000..f4cec22
--- /dev/null
+++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/Compose.jvm.kt
@@ -0,0 +1,5 @@
+package com.pr0gramm3r101.utils
+
+import androidx.compose.ui.Modifier
+
+actual fun Modifier.imeScrollWithKeyboard(): Modifier = this
diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/ImeMotion.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/ImeMotion.jvm.kt
new file mode 100644
index 0000000..171f0dd
--- /dev/null
+++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/ImeMotion.jvm.kt
@@ -0,0 +1,11 @@
+package com.pr0gramm3r101.utils
+
+import androidx.compose.runtime.Composable
+
+@Composable
+actual fun rememberImeMotion(): ImeMotion =
+ ImeMotion(
+ currentBottomPx = 0,
+ sourceBottomPx = 0,
+ targetBottomPx = 0,
+ )
diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/SystemBarsVisibility.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/SystemBarsVisibility.jvm.kt
new file mode 100644
index 0000000..fc4bbd7
--- /dev/null
+++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/SystemBarsVisibility.jvm.kt
@@ -0,0 +1,11 @@
+package com.pr0gramm3r101.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+@Composable
+actual fun rememberSystemBarsController(): ((Boolean) -> Unit)? {
+ return remember {
+ { _ -> }
+ }
+}
diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/Utils.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/Utils.jvm.kt
new file mode 100644
index 0000000..fd723fb
--- /dev/null
+++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/Utils.jvm.kt
@@ -0,0 +1,24 @@
+package com.pr0gramm3r101.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+actual fun Modifier.clearFocusOnKeyboardDismiss() = this
+
+@Composable
+actual fun ToggleNavScrimEffect(enabled: Boolean) {}
+
+@Composable
+actual fun DialogEdgeToEdgeEffect() {}
+
+actual val materialYouAvailable get() = false
+
+actual fun currentDeviceInfo(): CurrentDeviceInfo =
+ CurrentDeviceInfo(
+ osName = System.getProperty("os.name")?.trim()?.takeIf { it.isNotEmpty() },
+ osVersion = System.getProperty("os.version")?.trim()?.takeIf { it.isNotEmpty() },
+ deviceType = "desktop",
+ deviceName = System.getProperty("user.name")?.trim()?.takeIf { it.isNotEmpty() },
+ brand = null,
+ model = System.getProperty("os.arch")?.trim()?.takeIf { it.isNotEmpty() },
+ )
diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt
new file mode 100644
index 0000000..d2fe31e
--- /dev/null
+++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/UtilsLibrary.kt
@@ -0,0 +1,5 @@
+package com.pr0gramm3r101.utils
+
+actual object UtilsLibrary {
+ actual fun init(vararg args: Any) {}
+}
diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.jvm.kt
new file mode 100644
index 0000000..0ad1538
--- /dev/null
+++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/crypto/Base64.jvm.kt
@@ -0,0 +1,9 @@
+package com.pr0gramm3r101.utils.crypto
+
+actual object Base64 {
+ actual fun encode(bytes: ByteArray): String =
+ java.util.Base64.getEncoder().encodeToString(bytes)
+
+ actual fun decode(base64: String): ByteArray =
+ runCatching { java.util.Base64.getDecoder().decode(base64) }.getOrDefault(ByteArray(0))
+}
diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.jvm.kt
new file mode 100644
index 0000000..ab1ce35
--- /dev/null
+++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/crypto/Hmac.jvm.kt
@@ -0,0 +1,15 @@
+package com.pr0gramm3r101.utils.crypto
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+
+actual object Hmac {
+ actual suspend fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray =
+ withContext(Dispatchers.Default) {
+ val mac = Mac.getInstance("HmacSHA256")
+ mac.init(SecretKeySpec(key, "HmacSHA256"))
+ mac.doFinal(data)
+ }
+}
diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.jvm.kt
new file mode 100644
index 0000000..c50d6fd
--- /dev/null
+++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.jvm.kt
@@ -0,0 +1,63 @@
+package com.pr0gramm3r101.utils.files
+
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardOpenOption
+
+internal actual fun expectExists(path: String): Boolean = File(path).exists()
+
+internal actual fun expectWriteBytes(path: String, bytes: ByteArray) {
+ val file = File(path)
+ file.parentFile?.mkdirs()
+ file.writeBytes(bytes)
+}
+
+internal actual fun expectAppendBytes(path: String, bytes: ByteArray) {
+ val file = File(path)
+ file.parentFile?.mkdirs()
+ Files.write(
+ file.toPath(),
+ bytes,
+ StandardOpenOption.CREATE,
+ StandardOpenOption.APPEND,
+ StandardOpenOption.WRITE,
+ )
+}
+
+internal actual fun expectFileSize(path: String): Long {
+ val file = File(path)
+ return if (file.exists()) file.length() else 0L
+}
+
+internal actual fun expectDelete(path: String) {
+ File(path).delete()
+}
+
+internal actual fun expectDeleteFilesWithPrefix(dirPath: String, namePrefix: String) {
+ val dir = File(dirPath)
+ if (!dir.isDirectory) return
+ dir.listFiles()?.forEach { file ->
+ if (file.name.startsWith(namePrefix)) {
+ file.delete()
+ }
+ }
+}
+
+internal actual fun expectListFileNamesInDirectory(dirPath: String): List {
+ val dir = File(dirPath)
+ if (!dir.isDirectory) return emptyList()
+ return dir.list()?.toList().orEmpty()
+}
+
+internal actual fun expectGetAppCacheDirectory(): String {
+ val home = System.getProperty("user.home")
+ return if (!home.isNullOrBlank()) {
+ File(home, ".fromchat/cache").absolutePath
+ } else {
+ File(System.getProperty("java.io.tmpdir"), "fromchat").absolutePath
+ }
+}
+
+internal actual fun expectEnsureDirectory(path: String) {
+ File(path).mkdirs()
+}
diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/settings/JvmSettings.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/settings/JvmSettings.kt
new file mode 100644
index 0000000..a71e4cf
--- /dev/null
+++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/settings/JvmSettings.kt
@@ -0,0 +1,48 @@
+package com.pr0gramm3r101.utils.settings
+
+import com.russhwolf.settings.ExperimentalSettingsApi
+import com.russhwolf.settings.PreferencesSettings
+import com.russhwolf.settings.coroutines.toSuspendSettings
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.util.prefs.Preferences
+
+@OptIn(ExperimentalSettingsApi::class)
+class JvmSettings(
+ nodeName: String,
+) : Settings {
+ private val suspendSettings =
+ PreferencesSettings(Preferences.userRoot().node(nodeName)).toSuspendSettings()
+
+ override suspend fun putString(key: String, value: String) = suspendSettings.putString(key, value)
+
+ override suspend fun getString(key: String, default: String) = suspendSettings.getString(key, default)
+
+ override suspend fun putInt(key: String, value: Int) = suspendSettings.putInt(key, value)
+
+ override suspend fun getInt(key: String, default: Int) = suspendSettings.getInt(key, default)
+
+ override suspend fun putLong(key: String, value: Long) = suspendSettings.putLong(key, value)
+
+ override suspend fun getLong(key: String, default: Long) = suspendSettings.getLong(key, default)
+
+ override suspend fun putFloat(key: String, value: Float) = suspendSettings.putFloat(key, value)
+
+ override suspend fun getFloat(key: String, default: Float) = suspendSettings.getFloat(key, default)
+
+ override suspend fun putBoolean(key: String, value: Boolean) = suspendSettings.putBoolean(key, value)
+
+ override suspend fun getBoolean(key: String, default: Boolean) = suspendSettings.getBoolean(key, default)
+
+ override suspend fun putStringSet(key: String, value: Set) = withContext(Dispatchers.IO) {
+ putStringList(key, value.toList())
+ }
+
+ override suspend fun getStringSet(key: String, default: Set) = withContext(Dispatchers.IO) {
+ getStringList(key, default.toList()).toSet()
+ }
+
+ override suspend fun remove(key: String) = suspendSettings.remove(key)
+
+ override suspend fun contains(key: String) = suspendSettings.hasKey(key)
+}
diff --git a/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.jvm.kt b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.jvm.kt
new file mode 100644
index 0000000..e0ca071
--- /dev/null
+++ b/utils/shared/src/jvmMain/kotlin/com/pr0gramm3r101/utils/settings/SettingsFactory.jvm.kt
@@ -0,0 +1,4 @@
+package com.pr0gramm3r101.utils.settings
+
+actual val settings: Settings get() = JvmSettings(nodeName = "ru.fromchat.settings")
+actual val secureSettings: Settings get() = JvmSettings(nodeName = "ru.fromchat.secure")