Implement desktop support

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-26 15:52:42 +03:00
Unverified
parent 24fae67efc
commit 2b46c88bf3
133 changed files with 5446 additions and 485 deletions
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="shared-jvm">
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
<root id="archive" name="shared-jvm.jar">
<element id="module-output" name="FromChat.utils.shared.jvmMain" />
</root>
</artifact>
</component>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" /> <bytecodeTargetLevel target="17" />
</component> </component>
</project> </project>
+1
View File
@@ -12,6 +12,7 @@
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/app/android" /> <option value="$PROJECT_DIR$/app/android" />
<option value="$PROJECT_DIR$/app/desktop" />
<option value="$PROJECT_DIR$/app/shared" /> <option value="$PROJECT_DIR$/app/shared" />
<option value="$PROJECT_DIR$/utils" /> <option value="$PROJECT_DIR$/utils" />
<option value="$PROJECT_DIR$/utils/android" /> <option value="$PROJECT_DIR$/utils/android" />
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.10" />
</component>
</project>
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">
+11 -10
View File
@@ -2,14 +2,14 @@ Read in other languages: [Русский](./README.md)
# FromChat # 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) [📥 Download](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram Channel](https://t.me/fromchat_ch) • [🖥️ Server](https://github.com/fromchat-messenger/backend)
## ✨ Features ## ✨ Features
- **Voice and video calls** — LiveKit - **Voice and video calls** — LiveKit
- **Screen sharing** during calls - **Screen sharing** during calls (Android)
- **Public chat** — server-wide community - **Public chat** — server-wide community
- **Direct messages** — legal encryption scheme; server-side E2EE planned - **Direct messages** — legal encryption scheme; server-side E2EE planned
- **Device management** — active sessions - **Device management** — active sessions
@@ -18,15 +18,16 @@ FromChat is a 100% free and open-source messenger. This repository is the cross-
## 📊 Client Comparison ## 📊 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 | Desktop replaces the former Electron client in the Web repo. Run with `./gradlew :app:desktop:run`.
| --- | --- | --- | --- |
| **Messaging and profiles** | ✅ | ✅ | ❌ |
| **Voice/video calls** | ✅ | ✅ | ❌ |
| **Screen sharing** | ✅ | ✅ | ❌ |
| **Message reactions** | ❌ | ✅ | ❌ |
| **Rich attachment support** | ✅ | ❌ | ❌ |
--- ---
+11 -10
View File
@@ -2,14 +2,14 @@
# FromChat # 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) [📥 Скачать](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram-канал](https://t.me/fromchat_ch) • [🖥️ Сервер](https://github.com/fromchat-messenger/backend)
## ✨ Возможности ## ✨ Возможности
- **Голосовые и видеозвонки** — LiveKit - **Голосовые и видеозвонки** — LiveKit
- **Демонстрация экрана** во время звонков - **Демонстрация экрана** во время звонков (Android)
- **Общий чат** — сообщество пользователей сервера - **Общий чат** — сообщество пользователей сервера
- **Личные сообщения** — легальная схема шифрования; E2EE на сервере планируется - **Личные сообщения** — легальная схема шифрования; 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`.
| --- | --- | --- | --- |
| **Обмен сообщениями и профили** | ✅ | ✅ | ❌ |
| **Голосовые/видеозвонки** | ✅ | ✅ | ❌ |
| **Демонстрация экрана** | ✅ | ✅ | ❌ |
| **Реакции на сообщения** | ❌ | ✅ | ❌ |
| **Расширенная поддержка вложений** | ✅ | ❌ | ❌ |
--- ---
+106
View File
@@ -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 = """
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>FromChat</string>
<key>CFBundleURLSchemes</key>
<array>
<string>fromchat</string>
</array>
</dict>
</array>
""".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"
}
}
@@ -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<String>): 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<String>) {
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))
}
}
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7555FF7A242A565900829871"
BuildableName = "FromChat.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7555FF7A242A565900829871"
BuildableName = "FromChat.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7555FF7A242A565900829871"
BuildableName = "FromChat.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+8
View File
@@ -27,6 +27,14 @@
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true/>
</dict> </dict>
<key>NSMicrophoneUsageDescription</key>
<string>FromChat needs microphone access for voice and video calls.</string>
<key>NSCameraUsageDescription</key>
<string>FromChat needs camera access for video calls.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>FromChat needs photo library access so you can attach images to messages.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>FromChat needs permission to save images you export from chats.</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
+36 -2
View File
@@ -75,6 +75,8 @@ kotlin {
freeCompilerArgs.addAll("-Xexpect-actual-classes") freeCompilerArgs.addAll("-Xexpect-actual-classes")
} }
jvm()
listOf( listOf(
iosArm64(), iosArm64(),
iosSimulatorArm64(), iosSimulatorArm64(),
@@ -107,8 +109,8 @@ kotlin {
implementation(libs.navigation.compose) implementation(libs.navigation.compose)
implementation(libs.compose.materialIconsExtended) implementation(libs.compose.materialIconsExtended)
implementation(libs.haze) implementation(libs.haze)
implementation(libs.haze.materials) implementation(libs.haze.blur)
implementation(libs.androidx.core.ktx) implementation(libs.haze.blur.materials)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
// Serialization // Serialization
@@ -142,6 +144,7 @@ kotlin {
} }
androidMain.dependencies { androidMain.dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.markdown.renderer.m3) implementation(libs.markdown.renderer.m3)
implementation(libs.bouncycastle.bcprov) implementation(libs.bouncycastle.bcprov)
implementation(libs.androidx.exifinterface) implementation(libs.androidx.exifinterface)
@@ -164,6 +167,37 @@ kotlin {
implementation(libs.multiplatform.crypto.libsodium.bindings) implementation(libs.multiplatform.crypto.libsodium.bindings)
implementation(libs.sqldelight.driver.native) 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"
} }
} }
@@ -0,0 +1,3 @@
package ru.fromchat
actual fun keepWebSocketAliveInBackground(): Boolean = false
@@ -93,10 +93,10 @@ import androidx.compose.ui.zIndex
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dev.chrisbanes.haze.HazeState 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.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import io.livekit.android.RoomOptions import io.livekit.android.RoomOptions
import io.livekit.android.compose.local.RoomScope import io.livekit.android.compose.local.RoomScope
@@ -275,7 +275,6 @@ private fun CallSlotSharedVideo(
} }
} }
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
actual fun CallMediaLayer( actual fun CallMediaLayer(
connect: LiveKitConnectSession?, connect: LiveKitConnectSession?,
@@ -551,7 +550,6 @@ actual fun CallMediaLayer(
} }
} }
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
private fun CallRoomContent( private fun CallRoomContent(
room: Room, room: Room,
@@ -651,7 +649,7 @@ private fun CallRoomContent(
val participants = rememberParticipants(room).value val participants = rememberParticipants(room).value
val local = room.localParticipant val local = room.localParticipant
val remote: Participant? = participants.firstOrNull { it != local } val remote: Participant? = participants.firstOrNull { it != local }
val hazeState = rememberHazeState(blurEnabled = showInCallControls) val hazeState = rememberHazeState()
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -1179,7 +1177,7 @@ private fun CallPreviewCluster(
} }
} }
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun PreviewTile( private fun PreviewTile(
room: Room, room: Room,
@@ -1198,7 +1196,7 @@ private fun PreviewTile(
val tileShape = RoundedCornerShape(16.dp) val tileShape = RoundedCornerShape(16.dp)
// Only blur the *tile content* when the track isn't available yet. // 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. // Do not blur the whole call scene; otherwise the opponent video stays blurred.
val hazeState = rememberHazeState(blurEnabled = ref == null || !showVideo) val hazeState = rememberHazeState()
Box( Box(
modifier = Modifier modifier = Modifier
.size(104.dp, 138.dp) .size(104.dp, 138.dp)
@@ -1223,11 +1221,16 @@ private fun PreviewTile(
innerClipShape = tileShape, innerClipShape = tileShape,
) )
} else { } else {
val placeholderHazeStyle = HazeMaterials.thick()
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
// Placeholder blur only for missing/disabled track. // 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)), .background(MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.55f)),
) )
} }
@@ -1246,7 +1249,6 @@ private fun PreviewTile(
} }
} }
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
private fun CallInlineControlBar( private fun CallInlineControlBar(
room: Room, room: Room,
@@ -1327,11 +1329,16 @@ private fun CallInlineControlBar(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val controlsHazeStyle = HazeMaterials.thick()
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.86f) .fillMaxWidth(0.86f)
.clip(RoundedCornerShape(28.dp)) .clip(RoundedCornerShape(28.dp))
.hazeEffect(state = hazeState, style = HazeMaterials.thick()) .hazeEffect(state = hazeState) {
blurEffect {
style = controlsHazeStyle
}
}
.background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.48f)), .background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.48f)),
) { ) {
Row( Row(
@@ -0,0 +1,3 @@
package ru.fromchat.ui.calls
internal actual fun notifyIncomingCall(title: String, body: String) = Unit
@@ -40,4 +40,6 @@ actual fun areAppNotificationsEnabled(): Boolean {
} else { } else {
true true
} }
} }
actual fun arePushNotificationsSupported(): Boolean = true
@@ -59,6 +59,7 @@
<string name="auth_captcha_body">Подтвердите, что вы человек, чтобы продолжить создание аккаунта.</string> <string name="auth_captcha_body">Подтвердите, что вы человек, чтобы продолжить создание аккаунта.</string>
<string name="auth_captcha_failed">Не удалось пройти проверку. Попробуйте ещё раз.</string> <string name="auth_captcha_failed">Не удалось пройти проверку. Попробуйте ещё раз.</string>
<string name="auth_yandex_webview_title">Яндекс ID</string> <string name="auth_yandex_webview_title">Яндекс ID</string>
<string name="auth_yandex_browser_body">Завершите вход в браузере</string>
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string> <string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string> <string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
<string name="chats">Чаты</string> <string name="chats">Чаты</string>
@@ -324,6 +325,7 @@
<string name="settings_notifications_permission_required">Откройте системные настройки и разрешите уведомления.</string> <string name="settings_notifications_permission_required">Откройте системные настройки и разрешите уведомления.</string>
<string name="settings_push_notifications">Push-уведомления</string> <string name="settings_push_notifications">Push-уведомления</string>
<string name="settings_push_notifications_d">Они помогут вам узнать, если появилось новое сообщение пока приложение закрыто.</string> <string name="settings_push_notifications_d">Они помогут вам узнать, если появилось новое сообщение пока приложение закрыто.</string>
<string name="settings_push_notifications_unavailable">Недоступны на этой платформе. Новые сообщения видны только пока приложение открыто.</string>
<string name="settings_devices_title">Устройства</string> <string name="settings_devices_title">Устройства</string>
<string name="settings_devices_empty">Нет активных сессий</string> <string name="settings_devices_empty">Нет активных сессий</string>
@@ -66,6 +66,7 @@
<string name="auth_captcha_body">Confirm youre human to continue creating your account.</string> <string name="auth_captcha_body">Confirm youre human to continue creating your account.</string>
<string name="auth_captcha_failed">Captcha verification failed. Please try again.</string> <string name="auth_captcha_failed">Captcha verification failed. Please try again.</string>
<string name="auth_yandex_webview_title">Yandex ID</string> <string name="auth_yandex_webview_title">Yandex ID</string>
<string name="auth_yandex_browser_body">Complete sign-in in your browser</string>
<string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string> <string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string>
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string> <string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
<!-- Main Screen --> <!-- Main Screen -->
@@ -353,6 +354,7 @@
<string name="settings_notifications_permission_required">Open system notification settings to allow alerts.</string> <string name="settings_notifications_permission_required">Open system notification settings to allow alerts.</string>
<string name="settings_push_notifications">Push notifications</string> <string name="settings_push_notifications">Push notifications</string>
<string name="settings_push_notifications_d">These will let you know if you got a new message when the app is closed.</string> <string name="settings_push_notifications_d">These will let you know if you got a new message when the app is closed.</string>
<string name="settings_push_notifications_unavailable">Not available on this platform. New messages are only visible while the app is open.</string>
<string name="settings_devices_title">Devices</string> <string name="settings_devices_title">Devices</string>
<string name="settings_devices_empty">No active sessions</string> <string name="settings_devices_empty">No active sessions</string>
@@ -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
@@ -29,6 +29,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.AppForeground import ru.fromchat.AppForeground
import ru.fromchat.keepWebSocketAliveInBackground
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.UpdateSyncManager
@@ -117,6 +118,7 @@ object WebSocketManager {
} }
private suspend fun awaitForeground() { private suspend fun awaitForeground() {
if (keepWebSocketAliveInBackground()) return
if (AppForeground.isInForeground.value) return if (AppForeground.isInForeground.value) return
AppForeground.isInForeground.first { it } AppForeground.isInForeground.first { it }
} }
@@ -369,7 +371,7 @@ object WebSocketManager {
} }
fun onNetworkAvailable() { fun onNetworkAvailable() {
if (!AppForeground.isInForeground.value) return if (!keepWebSocketAliveInBackground() && !AppForeground.isInForeground.value) return
val now = Clock.System.now().toEpochMilliseconds() val now = Clock.System.now().toEpochMilliseconds()
val prev = lastOnNetworkAvailableWallMs val prev = lastOnNetworkAvailableWallMs
@@ -393,7 +395,7 @@ object WebSocketManager {
* Restarts the reconnect loop immediately instead of waiting out exponential backoff. * Restarts the reconnect loop immediately instead of waiting out exponential backoff.
*/ */
fun onServerLikelyReachable() { fun onServerLikelyReachable() {
if (!AppForeground.isInForeground.value) return if (!keepWebSocketAliveInBackground() && !AppForeground.isInForeground.value) return
if (session != null) return if (session != null) return
if (ApiClient.token.isNullOrEmpty()) return if (ApiClient.token.isNullOrEmpty()) return
@@ -30,36 +30,25 @@ object PublicChatProfileCache {
*/ */
fun hydrateFromDiskImmediate(instanceId: String): PublicChatProfile? { fun hydrateFromDiskImmediate(instanceId: String): PublicChatProfile? {
val id = instanceId.trim() val id = instanceId.trim()
synchronized(this) { if (id.isEmpty()) return _profile.value
if (id.isEmpty()) { if (loadedInstanceId == id && isCompleteProfile(_profile.value)) return _profile.value
return _profile.value val previousInstanceId = loadedInstanceId
} loadedInstanceId = id
if (loadedInstanceId == id && isCompleteProfile(_profile.value)) { val disk = PublicChatProfileCacheStore.getImmediate(id)
return _profile.value _profile.value = when {
} disk != null -> disk
val previousInstanceId = loadedInstanceId previousInstanceId.isEmpty() -> _profile.value
loadedInstanceId = id previousInstanceId != id -> null
val disk = PublicChatProfileCacheStore.getImmediate(id) else -> _profile.value
_profile.value = when {
disk != null -> disk
previousInstanceId.isEmpty() -> _profile.value
previousInstanceId != id -> null
else -> _profile.value
}
return _profile.value
} }
return _profile.value
} }
fun put(profile: PublicChatProfile) { fun put(profile: PublicChatProfile) {
val instanceId: String _profile.value = profile
synchronized(this) { val instanceId = resolvedInstanceId()
_profile.value = profile
instanceId = resolvedInstanceId()
if (instanceId.isNotEmpty()) {
loadedInstanceId = instanceId
}
}
if (instanceId.isNotEmpty()) { if (instanceId.isNotEmpty()) {
loadedInstanceId = instanceId
runCatching { PublicChatProfileCacheStore.putImmediate(instanceId, profile) } runCatching { PublicChatProfileCacheStore.putImmediate(instanceId, profile) }
} }
} }
@@ -73,10 +62,8 @@ object PublicChatProfileCache {
} }
suspend fun clear() { suspend fun clear() {
synchronized(this) { _profile.value = null
_profile.value = null loadedInstanceId = ""
loadedInstanceId = ""
}
} }
private fun resolvedInstanceId(): String = private fun resolvedInstanceId(): String =
@@ -54,6 +54,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.ToggleNavScrimEffect import com.pr0gramm3r101.utils.ToggleNavScrimEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
@@ -101,7 +102,7 @@ fun DocumentScreen(
) { ) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val listState = rememberLazyListState() val listState = rememberLazyListState()
val hazeState = rememberHazeState(blurEnabled = true) val hazeState = rememberHazeState()
LaunchedEffect(type) { LaunchedEffect(type) {
listState.scrollToItem(0) listState.scrollToItem(0)
@@ -116,6 +117,7 @@ fun DocumentScreen(
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
contentWindowInsets = WindowInsets.navigationBars, contentWindowInsets = WindowInsets.navigationBars,
topBar = { topBar = {
val topBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
MediumTopAppBar( MediumTopAppBar(
title = { title = {
Text( Text(
@@ -141,10 +143,11 @@ fun DocumentScreen(
), ),
modifier = Modifier modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f)) .background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
.hazeEffect( .hazeEffect(state = hazeState) {
state = hazeState, blurEffect {
style = rememberChatSurfaceContainerHazeStyle(), style = topBarHazeStyle
), }
},
) )
}, },
) { innerPadding -> ) { innerPadding ->
@@ -12,6 +12,7 @@ import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.coroutines.runBlocking
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -52,8 +53,6 @@ object AppLogStore {
private var loadedFromDisk = false private var loadedFromDisk = false
private var nextEntryId = 0L private var nextEntryId = 0L
private val writeLock = Any()
fun record( fun record(
level: AppLogLevel, level: AppLogLevel,
tag: String, tag: String,
@@ -68,8 +67,14 @@ object AppLogStore {
message = message, message = message,
stackTrace = throwable?.stackTraceToString(), stackTrace = throwable?.stackTraceToString(),
) )
synchronized(writeLock) { if (mutex.tryLock()) {
appendEntry(entry) try {
appendEntry(entry)
} finally {
mutex.unlock()
}
} else {
runBlocking { mutex.withLock { appendEntry(entry) } }
} }
} }
@@ -11,9 +11,13 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -24,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NamedNavArgument import androidx.navigation.NamedNavArgument
@@ -33,6 +38,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import coil3.ImageLoader import coil3.ImageLoader
@@ -41,8 +47,11 @@ import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.svg.SvgDecoder import coil3.svg.SvgDecoder
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import com.pr0gramm3r101.utils.LocalSystemBarsVisibility import com.pr0gramm3r101.utils.LocalSystemBarsVisibility
import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.navigateAndWipeBackStack import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.rememberSystemBarsController import com.pr0gramm3r101.utils.rememberSystemBarsController
import com.pr0gramm3r101.utils.widthSizeClass
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -55,6 +64,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.AppForeground import ru.fromchat.AppForeground
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.keepWebSocketAliveInBackground
import ru.fromchat.api.DeferredStartupNetwork import ru.fromchat.api.DeferredStartupNetwork
import ru.fromchat.api.ProfileUpdateSync import ru.fromchat.api.ProfileUpdateSync
import ru.fromchat.api.PublicChatProfileSync import ru.fromchat.api.PublicChatProfileSync
@@ -116,6 +126,10 @@ import ru.fromchat.utils.NetworkConnectivity
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") } val LocalNavController = compositionLocalOf<NavController> { 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<Float>(durationMillis = 250, easing = FastOutSlowInEasing) private val rootNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
private fun rootNavEnterTransition(): EnterTransition = private fun rootNavEnterTransition(): EnterTransition =
@@ -281,9 +295,14 @@ fun App(
} }
// Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]). // 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 val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
fun syncForeground() { fun syncForeground() {
if (keepWebSocketAliveInBackground()) {
AppForeground.setForeground(true)
return
}
AppForeground.setForeground( AppForeground.setForeground(
lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) 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 -> {} else -> {}
} }
} }
@@ -413,16 +436,37 @@ fun App(
LocalSystemBarsVisibility provides rememberSystemBarsController() LocalSystemBarsVisibility provides rememberSystemBarsController()
) { ) {
if (startDestination != null) { if (startDestination != null) {
val currentEntry by navController.currentBackStackEntryAsState()
val showConversationListDetail =
currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.EXPANDED &&
isConversationChatRoute(currentEntry?.destination?.route)
ScreenSurface { ScreenSurface {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
NavHost( Row(Modifier.fillMaxSize()) {
navController = navController, if (showConversationListDetail) {
startDestination = startDestination!!, Box(
enterTransition = { rootNavEnterTransition() }, modifier = Modifier
exitTransition = { rootNavExitTransition() }, .width(360.dp)
popEnterTransition = { rootNavPopEnterTransition() }, .fillMaxHeight(),
popExitTransition = { rootNavPopExitTransition() }, ) {
) { 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") { composable("serverConfig") {
ServerConfigScreen() ServerConfigScreen()
} }
@@ -574,7 +618,7 @@ fun App(
), ),
) { entry -> ) { entry ->
val focusField = EditProfileFocusField.fromArg( val focusField = EditProfileFocusField.fromArg(
entry.arguments?.getString(ProfileRoutes.ARG_FOCUS), entry.savedStateHandle.get<String>(ProfileRoutes.ARG_FOCUS),
) )
EditProfileScreen( EditProfileScreen(
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
@@ -733,8 +777,10 @@ fun App(
ApiClient.onAuthError = null ApiClient.onAuthError = null
} }
} }
}
}
CallOverlay(Modifier.fillMaxSize()) CallOverlay(Modifier.fillMaxSize())
} }
} }
} }
@@ -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 = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
<style>
html, body {
margin: 0; padding: 0; width: 100%; height: 100%;
background: #121212; overflow: hidden; font-family: system-ui, sans-serif;
}
#stage { position: relative; width: 100%; height: 100%; background: #000; }
#remoteVideo, #remoteAudioHost video {
position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: #000;
}
#localVideo {
position: absolute; right: 12px; top: 12px; width: 28%; max-width: 160px;
aspect-ratio: 3/4; object-fit: cover; border-radius: 12px; background: #333;
z-index: 2; transform: scaleX(-1);
}
#status {
position: absolute; left: 16px; right: 16px; top: 42%;
text-align: center; color: #e8e8e8; font-size: 15px; z-index: 3;
pointer-events: none;
}
#remoteAudioHost { position: absolute; width: 0; height: 0; overflow: hidden; }
</style>
</head>
<body>
<div id="stage">
<video id="remoteVideo" autoplay playsinline></video>
<video id="localVideo" autoplay playsinline muted></video>
<div id="remoteAudioHost"></div>
<div id="status">Connecting…</div>
</div>
<script src="$LIVEKIT_CDN"></script>
<script>
(function () {
var LK = window.LivekitClient;
var room = null;
var statusEl = document.getElementById("status");
var remoteVideo = document.getElementById("remoteVideo");
var localVideo = document.getElementById("localVideo");
var remoteAudioHost = document.getElementById("remoteAudioHost");
function setStatus(text) {
if (statusEl) statusEl.textContent = text || "";
}
function notifyNative(method, message) {
try {
if (window.FromChatNative && typeof window.FromChatNative[method] === "function") {
window.FromChatNative[method](message || "");
return;
}
} catch (e) {}
try {
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.FromChatCall) {
window.webkit.messageHandlers.FromChatCall.postMessage({
method: method,
message: message || ""
});
}
} catch (e2) {}
}
function attachRemoteTrack(track) {
if (!track) return;
if (track.kind === "video") {
track.attach(remoteVideo);
setStatus("");
} else if (track.kind === "audio") {
var el = track.attach();
remoteAudioHost.appendChild(el);
}
}
function detachAll() {
try {
if (room) {
room.remoteParticipants.forEach(function (p) {
p.trackPublications.forEach(function (pub) {
if (pub.track) pub.track.detach();
});
});
}
} catch (e) {}
remoteAudioHost.innerHTML = "";
}
async function connect(url, token) {
if (!LK) {
setStatus("LiveKit failed to load");
notifyNative("onConnectFailed", "LiveKit client failed to load");
return;
}
try {
if (room) {
await room.disconnect();
room = null;
}
detachAll();
setStatus("Connecting…");
room = new LK.Room({ adaptiveStream: true, dynacast: true });
room.on(LK.RoomEvent.TrackSubscribed, function (track) {
attachRemoteTrack(track);
});
room.on(LK.RoomEvent.TrackUnsubscribed, function (track) {
try { track.detach(); } catch (e) {}
});
room.on(LK.RoomEvent.Disconnected, function () {
setStatus("");
});
room.on(LK.RoomEvent.MediaDevicesError, function (e) {
notifyNative("onConnectFailed", (e && e.message) ? e.message : "Media device error");
});
await room.connect(url, token);
await room.localParticipant.setMicrophoneEnabled(true);
try {
await room.localParticipant.setCameraEnabled(true);
} catch (camErr) {
// Audio-only is fine if camera is denied / unavailable.
}
var camPub = room.localParticipant.getTrackPublication(LK.Track.Source.Camera);
if (camPub && camPub.track) {
camPub.track.attach(localVideo);
}
room.remoteParticipants.forEach(function (p) {
p.trackPublications.forEach(function (pub) {
if (pub.isSubscribed && pub.track) attachRemoteTrack(pub.track);
});
});
setStatus("");
notifyNative("onConnected", "");
} catch (err) {
var msg = (err && err.message) ? err.message : String(err);
setStatus(msg);
notifyNative("onConnectFailed", msg);
}
}
window.FromChatCallCtl = {
setMicEnabled: function (on) {
if (!room) return Promise.resolve();
return room.localParticipant.setMicrophoneEnabled(!!on);
},
setCamEnabled: function (on) {
if (!room) return Promise.resolve();
return room.localParticipant.setCameraEnabled(!!on).then(function () {
var camPub = room.localParticipant.getTrackPublication(LK.Track.Source.Camera);
if (on && camPub && camPub.track) {
camPub.track.attach(localVideo);
} else if (!on) {
try { localVideo.srcObject = null; } catch (e) {}
}
});
},
disconnect: function () {
if (!room) return Promise.resolve();
var r = room;
room = null;
detachAll();
return r.disconnect();
},
connect: connect
};
window.fromChatConnect = connect;
})();
</script>
</body>
</html>
""".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('"')
}
}
@@ -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)
@@ -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),
)
}
}
}
@@ -1,5 +1,3 @@
@file:OptIn(ExperimentalHazeMaterialsApi::class)
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
@@ -68,10 +66,10 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.pr0gramm3r101.utils.crypto.Base64 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.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -122,7 +120,6 @@ internal fun isImageFilename(name: String): Boolean =
name.endsWith(".png", true) || name.endsWith(".jpg", true) || name.endsWith(".png", true) || name.endsWith(".jpg", true) ||
name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true) name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true)
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun AttachmentPreview( fun AttachmentPreview(
file: DmFile?, file: DmFile?,
@@ -313,7 +310,7 @@ fun AttachmentPreview(
} }
} }
@OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun ChatImageTileContent( private fun ChatImageTileContent(
messageId: Int, messageId: Int,
@@ -458,7 +455,8 @@ private fun ChatImageTileContent(
val imageContentScale = ContentScale.Crop val imageContentScale = ContentScale.Crop
val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f) 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) { LaunchedEffect(messageId, fileIndex, cacheClientId) {
DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri -> DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri ->
@@ -793,10 +791,11 @@ private fun ChatImageTileContent(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.hazeEffect( .hazeEffect(state = thumbHazeState) {
state = thumbHazeState, blurEffect {
style = HazeMaterials.thin(), style = thumbOverlayHazeStyle
), }
},
) )
} }
} }
@@ -918,7 +917,6 @@ private fun ChatImageTileContent(
} }
} }
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
private fun DownloadCancelledImageOverlay( private fun DownloadCancelledImageOverlay(
isAuthor: Boolean, isAuthor: Boolean,
@@ -926,9 +924,14 @@ private fun DownloadCancelledImageOverlay(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scrim = MaterialTheme.colorScheme.scrim.copy(alpha = 0.38f) val scrim = MaterialTheme.colorScheme.scrim.copy(alpha = 0.38f)
val overlayHazeStyle = HazeMaterials.thin()
Box( Box(
modifier = modifier modifier = modifier
.hazeEffect(style = HazeMaterials.thin()) .hazeEffect {
blurEffect {
style = overlayHazeStyle
}
}
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.12f)), .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -958,7 +961,6 @@ private fun DownloadCancelledImageOverlay(
} }
} }
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
private fun UploadingImageOverlay( private fun UploadingImageOverlay(
model: String?, model: String?,
@@ -969,12 +971,17 @@ private fun UploadingImageOverlay(
onCancelUpload: (() -> Unit)? = null, onCancelUpload: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val overlayHazeStyle = HazeMaterials.thin()
Box(modifier = modifier) { Box(modifier = modifier) {
Box( Box(
modifier = Modifier modifier = Modifier
.matchParentSize() .matchParentSize()
.clip(clipShape) .clip(clipShape)
.hazeEffect(style = HazeMaterials.thin()) .hazeEffect {
blurEffect {
style = overlayHazeStyle
}
}
) { ) {
when { when {
previewBitmap != null -> { previewBitmap != null -> {
@@ -1084,7 +1091,6 @@ internal fun ExpressiveUploadIndicator(
} }
} }
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
private fun PendingImageContent( private fun PendingImageContent(
uri: String, uri: String,
@@ -1260,10 +1266,15 @@ private fun CorruptedImagePlaceholder(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
clipShape: RoundedCornerShape = attachmentImageCornerShape(isAuthor = false), clipShape: RoundedCornerShape = attachmentImageCornerShape(isAuthor = false),
) { ) {
val overlayHazeStyle = HazeMaterials.thin()
Box( Box(
modifier = modifier modifier = modifier
.clip(clipShape) .clip(clipShape)
.hazeEffect(style = HazeMaterials.thin()), .hazeEffect {
blurEffect {
style = overlayHazeStyle
}
},
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
@@ -72,6 +72,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -244,6 +245,7 @@ fun ChatInput(
isReadOnly: Boolean = false, isReadOnly: Boolean = false,
onReadOnlyMessageClick: () -> Unit = {}, onReadOnlyMessageClick: () -> Unit = {},
snackbarHostState: SnackbarHostState? = null, snackbarHostState: SnackbarHostState? = null,
hazeBlurEnabled: Boolean = true,
) { ) {
val composerHazeStyle = rememberChatSurfaceContainerHazeStyle() val composerHazeStyle = rememberChatSurfaceContainerHazeStyle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -313,7 +315,12 @@ fun ChatInput(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(pillShape) .clip(pillShape)
.hazeEffect(state = hazeState, style = composerHazeStyle) .hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
style = composerHazeStyle
}
}
.clickable(enabled = true) { onReadOnlyMessageClick() }, .clickable(enabled = true) { onReadOnlyMessageClick() },
) { ) {
Text( Text(
@@ -344,7 +351,12 @@ fun ChatInput(
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer, pillShape) .background(MaterialTheme.colorScheme.surfaceContainer, pillShape)
.clip(pillShape) .clip(pillShape)
.hazeEffect(state = hazeState, style = composerHazeStyle), .hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
style = composerHazeStyle
}
},
) { ) {
AnimatedPreviewBar(replyTo) { reply -> AnimatedPreviewBar(replyTo) { reply ->
val replySubtitle = if (reply.isContentCorrupted) { val replySubtitle = if (reply.isContentCorrupted) {
@@ -550,10 +562,14 @@ fun ChatInput(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(actionBackground) .background(actionBackground)
.hazeEffect(state = hazeState, style = composerHazeStyle) { .hazeEffect(state = hazeState) {
// Keep the node in the tree in both directions; fading alpha avoids the blurEffect {
// frosted layer popping on over the color when send -> voice. // Keep the node in the tree in both directions; fading alpha avoids the
alpha = hazeOverlayAlpha // frosted layer popping on over the color when send -> voice.
blurEnabled = hazeBlurEnabled
style = composerHazeStyle
alpha = hazeOverlayAlpha
}
}, },
) )
AnimatedContent( AnimatedContent(
@@ -79,11 +79,11 @@ import ru.fromchat.utils.rememberRegistrationDateFormatStrings
import com.pr0gramm3r101.utils.resetFocus import com.pr0gramm3r101.utils.resetFocus
import com.pr0gramm3r101.utils.supportClipboardManagerImpl import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import dev.chrisbanes.haze.HazeState 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.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -157,7 +157,7 @@ import ru.fromchat.utils.rememberLastSeenFormatStrings
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatScreen( fun ChatScreen(
panel: ChatPanel, panel: ChatPanel,
@@ -426,9 +426,8 @@ fun ChatScreen(
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val profileUserId = panelState.profileUserId val profileUserId = panelState.profileUserId
val hazeState = rememberHazeState( val hazeState = rememberHazeState()
blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty()) val hazeBlurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
)
val currentTypingUsers = panelState.typingUsers // Directly use from panelState val currentTypingUsers = panelState.typingUsers // Directly use from panelState
val statusMap by UserStatusStore.status.collectAsState() 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”. // floating header Box only; avoids extra top inset on the fade / list and duplicate “padding”.
contentWindowInsets = WindowInsets.navigationBars, contentWindowInsets = WindowInsets.navigationBars,
bottomBar = { bottomBar = {
val bottomBarHazeStyle = HazeMaterials.thin()
Column( Column(
modifier = Modifier modifier = Modifier
.windowInsetsPadding(WindowInsets.ime) .windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer) .background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) { .hazeEffect(state = hazeState) {
progressive = HazeProgressive.verticalGradient( blurEffect {
startIntensity = 0f, blurEnabled = hazeBlurEnabled
endIntensity = 1f, style = bottomBarHazeStyle
) progressive = HazeProgressive.verticalGradient(
startIntensity = 0f,
endIntensity = 1f,
)
}
} }
) { ) {
if (peerDeleted && dmRecipientId != null) { if (peerDeleted && dmRecipientId != null) {
@@ -1045,6 +1049,7 @@ fun ChatScreen(
hazeState = hazeState, hazeState = hazeState,
supportsAttachments = panel.supportsAttachments, supportsAttachments = panel.supportsAttachments,
isReadOnly = isReadOnly, isReadOnly = isReadOnly,
hazeBlurEnabled = hazeBlurEnabled,
onReadOnlyMessageClick = { onReadOnlyMessageClick = {
if (isReadOnly) { if (isReadOnly) {
showSuspendedSupportSheet = true showSuspendedSupportSheet = true
@@ -1383,11 +1388,13 @@ fun ChatScreen(
}, },
contentDescription = scrollToBottomCd, contentDescription = scrollToBottomCd,
hazeState = hazeState, hazeState = hazeState,
hazeBlurEnabled = hazeBlurEnabled,
) )
} }
ChatTopBar( ChatTopBar(
hazeState = hazeState, hazeState = hazeState,
hazeBlurEnabled = hazeBlurEnabled,
onBack = { onBack = {
runNav { navController.navigateUp() } runNav { navController.navigateUp() }
}, },
@@ -1583,6 +1590,7 @@ private fun ChatScrollToBottomButton(
contentDescription: String, contentDescription: String,
hazeState: HazeState, hazeState: HazeState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hazeBlurEnabled: Boolean = true,
) { ) {
val hazeStyle = rememberChatSurfaceContainerHazeStyle() val hazeStyle = rememberChatSurfaceContainerHazeStyle()
Box( Box(
@@ -1598,7 +1606,12 @@ private fun ChatScrollToBottomButton(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f)) .background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
.hazeEffect(state = hazeState, style = hazeStyle), .hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
style = hazeStyle
}
},
) )
Icon( Icon(
imageVector = Icons.Rounded.KeyboardArrowDown, imageVector = Icons.Rounded.KeyboardArrowDown,
@@ -62,8 +62,9 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.conditional import com.pr0gramm3r101.utils.conditional
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.blur.HazeBlurStyle
import dev.chrisbanes.haze.HazeTint import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -325,6 +326,7 @@ fun ChatTopBar(
callContentDescription: String, callContentDescription: String,
titleChrome: @Composable () -> Unit, titleChrome: @Composable () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hazeBlurEnabled: Boolean = true,
) { ) {
val bottomCornerRadius = ChatFloatingHeaderBottomArcRadius val bottomCornerRadius = ChatFloatingHeaderBottomArcRadius
// [BottomInsetTopBarShape] draws the arc in the strip from y = (content height) .. (content + r); // [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 layoutHeight = topBarPlaceable.height + arcExtentPx
val bgPlaceable = subcompose("background") { val bgPlaceable = subcompose("background") {
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -379,7 +382,12 @@ fun ChatTopBar(
} }
) )
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f)) .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)) }.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] * [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.materials.HazeMaterials]. * (24dp blur + luminance-scaled tint, slightly denser than old thin defaults), without
* [dev.chrisbanes.haze.blur.materials.HazeMaterials].
*/ */
@Composable @Composable
fun rememberChatSurfaceContainerHazeStyle(): HazeStyle { fun rememberChatSurfaceContainerHazeStyle(): HazeBlurStyle {
val surface = MaterialTheme.colorScheme.surfaceContainer val surface = MaterialTheme.colorScheme.surfaceContainer
return remember(surface) { return remember(surface) {
HazeStyle( HazeBlurStyle(
blurRadius = 24.dp, blurRadius = 24.dp,
backgroundColor = surface, 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),
),
) )
} }
} }
@@ -534,7 +534,7 @@ fun MessageItem(
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier.weight(1f, fill = false) modifier = Modifier.weight(1f, fill = false)
) { ) {
val maxBubbleWidth = maxWidth * 0.7f val maxBubbleWidth = minOf(maxWidth * 0.7f, 420.dp)
Column( Column(
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
@@ -13,7 +13,7 @@ data class SelectedAttachment(
/** /**
* Platform-specific image picker. Returns a launch function. * Platform-specific image picker. Returns a launch function.
* On Android: PickVisualMedia (images only, multiple). * On Android: PickVisualMedia (images only, multiple).
* On iOS: Placeholder (no-op until Phase 4). * On iOS: PHPickerViewController (images only, multiple).
*/ */
@Composable @Composable
expect fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit expect fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit
@@ -21,7 +21,7 @@ expect fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit
/** /**
* Platform-specific file picker. Returns a launch function. * Platform-specific file picker. Returns a launch function.
* On Android: OpenDocument/OpenMultipleDocuments (SAF). * On Android: OpenDocument/OpenMultipleDocuments (SAF).
* On iOS: Placeholder (no-op until Phase 4). * On iOS: UIDocumentPickerViewController (multiple, asCopy).
*/ */
@Composable @Composable
expect fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit expect fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit
@@ -83,12 +83,12 @@ import androidx.compose.ui.zIndex
import androidx.graphics.shapes.Morph import androidx.graphics.shapes.Morph
import androidx.graphics.shapes.RoundedPolygon import androidx.graphics.shapes.RoundedPolygon
import com.pr0gramm3r101.utils.LastAnchoredBottomArrangement import com.pr0gramm3r101.utils.LastAnchoredBottomArrangement
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState 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.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -333,7 +333,6 @@ fun MorphedExpressiveStepButton(
@OptIn( @OptIn(
ExperimentalAnimationApi::class, ExperimentalAnimationApi::class,
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
) )
@Composable @Composable
fun ExpressiveStepFlowScaffold( fun ExpressiveStepFlowScaffold(
@@ -549,6 +548,7 @@ fun ExpressiveStepFlowScaffold(
@Composable @Composable
fun HazeTopBar(hazeState: HazeState) { fun HazeTopBar(hazeState: HazeState) {
val topBarHazeStyle = HazeMaterials.thin()
TopAppBar( TopAppBar(
title = {}, title = {},
navigationIcon = { navigationIcon = {
@@ -566,11 +566,14 @@ fun ExpressiveStepFlowScaffold(
containerColor = Color.Transparent, containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent, scrolledContainerColor = Color.Transparent,
), ),
modifier = Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) { modifier = Modifier.hazeEffect(state = hazeState) {
progressive = HazeProgressive.verticalGradient( blurEffect {
startIntensity = 1f, style = topBarHazeStyle
endIntensity = 0f, progressive = HazeProgressive.verticalGradient(
) startIntensity = 1f,
endIntensity = 0f,
)
}
}, },
) )
} }
@@ -12,30 +12,37 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState 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.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun HazeBottomBar( fun HazeBottomBar(
hazeState: HazeState, hazeState: HazeState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hazeStyle: HazeStyle? = null, hazeStyle: HazeBlurStyle? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val resolvedStyle = hazeStyle ?: HazeMaterials.thin()
val effectModifier = if (hazeStyle != null) { val effectModifier = if (hazeStyle != null) {
Modifier.hazeEffect(state = hazeState, style = hazeStyle) Modifier.hazeEffect(state = hazeState) {
blurEffect {
style = resolvedStyle
}
}
} else { } else {
Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) { Modifier.hazeEffect(state = hazeState) {
progressive = HazeProgressive.verticalGradient( blurEffect {
startIntensity = 0f, style = resolvedStyle
endIntensity = 1f, progressive = HazeProgressive.verticalGradient(
) startIntensity = 0f,
endIntensity = 1f,
)
}
} }
} }
Column( Column(
@@ -57,14 +64,13 @@ fun HazeBottomBar(
} }
} }
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun HazeActionButton( fun HazeActionButton(
onClick: () -> Unit, onClick: () -> Unit,
hazeState: HazeState, hazeState: HazeState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier, innerModifier: Modifier = Modifier,
hazeStyle: HazeStyle? = null, hazeStyle: HazeBlurStyle? = null,
enabled: Boolean = true, enabled: Boolean = true,
loading: Boolean = false, loading: Boolean = false,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
@@ -80,4 +86,4 @@ fun HazeActionButton(
content = content, content = content,
) )
} }
} }
@@ -6,9 +6,11 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding 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.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex 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.HazeState
import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.blur.HazeBlurStyle
import dev.chrisbanes.haze.HazeTint import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -61,6 +67,7 @@ import ru.fromchat.contacts
import ru.fromchat.profile import ru.fromchat.profile
import ru.fromchat.settings import ru.fromchat.settings
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.FromChatSnackbarHost import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController 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_PROFILE = 3
private const val PAGE_COUNT = 4 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 listdetail conversation layout.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
snackbarHostState: SnackbarHostState? = null, snackbarHostState: SnackbarHostState? = null,
listPaneOnly: Boolean = false,
) { ) {
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() } val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
val navController = LocalNavController.current val navController = LocalNavController.current
@@ -90,11 +102,14 @@ fun MainScreen(
) )
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val density = LocalDensity.current val density = LocalDensity.current
val navBarHazeState = rememberHazeState(blurEnabled = true) val navBarHazeState = rememberHazeState()
val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle() val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
val contextMenuHazeState = rememberHazeState(blurEnabled = true) val contextMenuHazeState = rememberHazeState()
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
val useNavigationRail = !listPaneOnly &&
currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
val selectedPage = pagerState.currentPage val selectedPage = pagerState.currentPage
val isChatsPage = selectedPage == PAGE_CHATS val isChatsPage = selectedPage == PAGE_CHATS
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
@@ -102,46 +117,83 @@ fun MainScreen(
val statusBarTopDp = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val statusBarTopDp = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
var bottomChromeHeightDp by remember { mutableStateOf(0.dp) } var bottomChromeHeightDp by remember { mutableStateOf(0.dp) }
val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp) { val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp, useNavigationRail, listPaneOnly) {
MainChromeInsets( MainChromeInsets(
top = statusBarTopDp, top = statusBarTopDp,
bottom = bottomChromeHeightDp, bottom = if (useNavigationRail || listPaneOnly) 0.dp else bottomChromeHeightDp,
) )
} }
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val chatsLabel = stringResource(Res.string.chats)
Box( val contactsLabel = stringResource(Res.string.contacts)
modifier = Modifier val settingsLabel = stringResource(Res.string.settings)
.fillMaxSize() val profileLabel = stringResource(Res.string.profile)
.hazeSource(contextMenuHazeState),
) { BoxWithConstraints(Modifier.fillMaxSize()) {
Scaffold( Row(Modifier.fillMaxSize()) {
snackbarHost = { if (useNavigationRail) {
FromChatSnackbarHost( SimpleNavigationRail(
hostState = effectiveSnackbarHostState, selectedItem = selectedPage,
modifier = Modifier.padding(bottom = mainChromeInsets.bottom), modifier = Modifier.fillMaxHeight(),
) NavigationItem(
}, name = chatsLabel,
containerColor = Color.Transparent, selectedIcon = Icons.AutoMirrored.Filled.Chat,
contentWindowInsets = WindowInsets(0, 0, 0, 0), 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 modifier = Modifier
.fillMaxSize() .weight(1f)
.imePadding(), .fillMaxHeight()
.hazeSource(contextMenuHazeState),
) { ) {
CompositionLocalProvider(LocalMainChromeInsets provides mainChromeInsets) { Scaffold(
Box( snackbarHost = {
modifier = Modifier FromChatSnackbarHost(
.fillMaxSize() hostState = effectiveSnackbarHostState,
.hazeSource(navBarHazeState), modifier = Modifier.padding(bottom = mainChromeInsets.bottom),
) { )
HorizontalPager( },
state = pagerState, containerColor = Color.Transparent,
modifier = Modifier.fillMaxSize(), contentWindowInsets = WindowInsets(0, 0, 0, 0),
beyondViewportPageCount = 1, modifier = Modifier
) { page -> .fillMaxSize()
when (page) { .imePadding(),
PAGE_CHATS -> ChatsTab( ) {
isVisible = isChatsPage, CompositionLocalProvider(LocalMainChromeInsets provides mainChromeInsets) {
Box(
modifier = Modifier
.fillMaxSize()
.hazeSource(navBarHazeState),
) {
if (listPaneOnly) {
ChatsTab(
isVisible = true,
onOpenSearch = { onOpenSearch = {
navController.navigate("search/conversations") navController.navigate("search/conversations")
}, },
@@ -149,81 +201,118 @@ fun MainScreen(
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
) )
PAGE_CONTACTS -> ContactsTab() } else {
PAGE_SETTINGS -> SettingsTab() HorizontalPager(
PAGE_PROFILE -> { state = pagerState,
ProfileScreen( modifier = Modifier.fillMaxSize(),
userId = ApiClient.user?.id, beyondViewportPageCount = 1,
onBack = {}, ) { page ->
onChat = { _ -> }, when (page) {
modifier = Modifier PAGE_CHATS -> ChatsTab(
.fillMaxSize() isVisible = isChatsPage,
.mainPagerBottomInset(), onOpenSearch = {
onOpenSettings = { navController.navigate("search/conversations")
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) } },
}, 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( if (!useNavigationRail && !listPaneOnly) {
modifier = Modifier Column(
.align(Alignment.BottomCenter) modifier = Modifier
.fillMaxWidth() .align(Alignment.BottomCenter)
.zIndex(1f) .fillMaxWidth()
.imePadding() .zIndex(1f)
.onSizeChanged { size -> .imePadding()
val measured = with(density) { size.height.toDp() } .onSizeChanged { size ->
if (measured != bottomChromeHeightDp) { val measured = with(density) { size.height.toDp() }
bottomChromeHeightDp = measured 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 @Composable
private fun ChatContextMenuBlurLayer( private fun ChatContextMenuBlurLayer(
hazeState: HazeState, hazeState: HazeState,
@@ -260,15 +348,16 @@ private fun ChatContextMenuBlurLayer(
if (blurRadius <= 0.dp) return if (blurRadius <= 0.dp) return
Box( Box(
modifier = modifier.hazeEffect( modifier = modifier.hazeEffect(state = hazeState) {
state = hazeState, blurEffect {
style = HazeStyle( style = HazeBlurStyle(
blurRadius = blurRadius, blurRadius = blurRadius,
tints = emptyList(), colorEffects = emptyList(),
backgroundColor = Color.Transparent, backgroundColor = Color.Transparent,
noiseFactor = 0f, noiseFactor = 0f,
fallbackTint = HazeTint(Color.Transparent), fallbackColorEffect = HazeColorEffect.tint(Color.Transparent),
), )
), }
},
) )
} }
@@ -59,10 +59,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import dev.chrisbanes.haze.HazeState 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.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -340,20 +340,23 @@ private fun chatsTopAppBarColors(blurReveal: Float) = TopAppBarDefaults.topAppBa
scrolledContainerColor = Color.Transparent, scrolledContainerColor = Color.Transparent,
) )
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
private fun ChatsTopBarHazeBackdrop( private fun ChatsTopBarHazeBackdrop(
hazeState: HazeState, hazeState: HazeState,
blurReveal: Float, blurReveal: Float,
blurEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val topBarHazeStyle = HazeMaterials.thin()
Box( Box(
modifier = modifier modifier = modifier
.graphicsLayer { alpha = blurReveal.coerceIn(0f, 1f) } .graphicsLayer { alpha = blurReveal.coerceIn(0f, 1f) }
.hazeEffect( .hazeEffect(state = hazeState) {
state = hazeState, blurEffect {
style = HazeMaterials.thin(), this.blurEnabled = blurEnabled
), style = topBarHazeStyle
}
},
) )
} }
@@ -518,7 +521,7 @@ private fun ChatsSelectionTopBar(
) )
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatsTab( fun ChatsTab(
isVisible: Boolean = true, isVisible: Boolean = true,
@@ -573,7 +576,7 @@ fun ChatsTab(
val publicChatProfile = publicChatProfileFromFlow ?: publicChatProfileFromDisk val publicChatProfile = publicChatProfileFromFlow ?: publicChatProfileFromDisk
val searchBarHint = stringResource(Res.string.search_title) val searchBarHint = stringResource(Res.string.search_title)
val tabListState = rememberLazyListState() val tabListState = rememberLazyListState()
val topChromeHazeState = rememberHazeState(blurEnabled = isVisible) val topChromeHazeState = rememberHazeState()
val statusMap by UserStatusStore.status.collectAsState() val statusMap by UserStatusStore.status.collectAsState()
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) } var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
val statusSubscriptionScope = rememberCoroutineScope() val statusSubscriptionScope = rememberCoroutineScope()
@@ -1032,6 +1035,7 @@ fun ChatsTab(
ChatsTopBarHazeBackdrop( ChatsTopBarHazeBackdrop(
hazeState = topChromeHazeState, hazeState = topChromeHazeState,
blurReveal = topBarBlurReveal, blurReveal = topBarBlurReveal,
blurEnabled = isVisible,
modifier = Modifier.matchParentSize(), modifier = Modifier.matchParentSize(),
) )
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
@@ -67,11 +67,11 @@ import coil3.compose.AsyncImage
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.utils.currentDeviceInfo 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.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import io.ktor.client.network.sockets.ConnectTimeoutException import io.ktor.client.network.sockets.ConnectTimeoutException
import io.ktor.client.network.sockets.SocketTimeoutException import io.ktor.client.network.sockets.SocketTimeoutException
@@ -363,13 +363,13 @@ private fun DeviceSessionDetailBottomSheet(
} }
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, @OptIn(ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class ExperimentalMaterial3ExpressiveApi::class
) )
@Composable @Composable
fun DevicesScreen(onBack: () -> Unit) { fun DevicesScreen(onBack: () -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val hazeState = rememberHazeState(blurEnabled = true) val hazeState = rememberHazeState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val initialCache = remember { Settings.readDeviceSessionsCache() } val initialCache = remember { Settings.readDeviceSessionsCache() }
@@ -430,6 +430,7 @@ fun DevicesScreen(onBack: () -> Unit) {
contentWindowInsets = WindowInsets.navigationBars, contentWindowInsets = WindowInsets.navigationBars,
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = { topBar = {
val topBarHazeStyle = HazeMaterials.thin()
TopAppBar( TopAppBar(
title = {}, title = {},
navigationIcon = { navigationIcon = {
@@ -441,14 +442,14 @@ fun DevicesScreen(onBack: () -> Unit) {
containerColor = Color.Transparent, containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent scrolledContainerColor = Color.Transparent
), ),
modifier = Modifier.hazeEffect( modifier = Modifier.hazeEffect(state = hazeState) {
state = hazeState, blurEffect {
style = HazeMaterials.thin() style = topBarHazeStyle
) { progressive = HazeProgressive.verticalGradient(
progressive = HazeProgressive.verticalGradient( startIntensity = 1f,
startIntensity = 1f, endIntensity = 0f,
endIntensity = 0f, )
) }
} }
) )
}, },
@@ -707,6 +707,7 @@ private fun formatLogFileSize(sizeBytes: Long): String {
if (sizeBytes < 1024 * 1024) { if (sizeBytes < 1024 * 1024) {
return stringResource(Res.string.logs_file_size_kb, kb) 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) return stringResource(Res.string.logs_file_size_mb, megabytes)
} }
@@ -44,6 +44,7 @@ import ru.fromchat.settings_notifications_permission_required
import ru.fromchat.settings_notifications_title import ru.fromchat.settings_notifications_title
import ru.fromchat.settings_push_notifications import ru.fromchat.settings_push_notifications
import ru.fromchat.settings_push_notifications_d 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.FromChatSnackbarHost
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
@@ -84,48 +85,58 @@ fun NotificationsScreen(onBack: () -> Unit) {
Category( Category(
modifier = Modifier.padding(top = 16.dp) modifier = Modifier.padding(top = 16.dp)
) { ) {
SwitchListItem( if (arePushNotificationsSupported()) {
headline = stringResource(Res.string.settings_push_notifications), SwitchListItem(
supportingText = stringResource(Res.string.settings_push_notifications_d), headline = stringResource(Res.string.settings_push_notifications),
leadingContent = { supportingText = stringResource(Res.string.settings_push_notifications_d),
Icon(Icons.Filled.Notifications, null) leadingContent = {
}, Icon(Icons.Filled.Notifications, null)
checked = notificationsEnabled, },
onCheckedChange = { enabled -> checked = notificationsEnabled,
coroutineScope.launch { onCheckedChange = { enabled ->
if (enabled) { coroutineScope.launch {
if (!areAppNotificationsEnabled()) { if (enabled) {
if (!openAppNotificationSettings()) { if (!areAppNotificationsEnabled()) {
snackbarHostState.showSnackbar(message = unexpectedErrorText) if (!openAppNotificationSettings()) {
} else { snackbarHostState.showSnackbar(message = unexpectedErrorText)
snackbarHostState.showSnackbar(message = notificationsPermissionText) } else {
snackbarHostState.showSnackbar(message = notificationsPermissionText)
}
return@launch
} }
return@launch
}
val registered = ensureFcmTokenRegistered() val registered = ensureFcmTokenRegistered()
if (registered) { if (registered) {
notificationsEnabled = true notificationsEnabled = true
} else {
snackbarHostState.showSnackbar(message = unexpectedErrorText)
}
} else { } else {
snackbarHostState.showSnackbar(message = unexpectedErrorText) unregisterFcmTokenFromServer()
notificationsEnabled = false
} }
} else {
unregisterFcmTokenFromServer()
notificationsEnabled = false
} }
} },
}, divider = true
divider = true )
)
ListItem( ListItem(
headline = stringResource(Res.string.settings_notification_settings), headline = stringResource(Res.string.settings_notification_settings),
supportingText = stringResource(Res.string.settings_notification_settings_d), supportingText = stringResource(Res.string.settings_notification_settings_d),
leadingContent = { leadingContent = {
Icon(Icons.Filled.Settings, null) Icon(Icons.Filled.Settings, null)
}, },
onClick = { openAppNotificationSettings() } 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. * Returns true when notifications are currently enabled for this app, including runtime permission.
*/ */
expect fun areAppNotificationsEnabled(): Boolean expect fun areAppNotificationsEnabled(): Boolean
/** False on platforms without push delivery (e.g. iOS without APNs). */
expect fun arePushNotificationsSupported(): Boolean
@@ -67,11 +67,11 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.navigateAndWipeBackStack import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.toDp 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.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -129,7 +129,6 @@ private object ServerConfigLazyListIndices {
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3ExpressiveApi::class,
ExperimentalAnimationApi::class, ExperimentalAnimationApi::class,
ExperimentalHazeMaterialsApi::class,
) )
@Composable @Composable
fun ServerConfigScreen() { fun ServerConfigScreen() {
@@ -331,6 +330,7 @@ fun ServerConfigScreen() {
} }
}, },
topBar = { topBar = {
val topBarHazeStyle = HazeMaterials.thin()
TopAppBar( TopAppBar(
title = {}, title = {},
navigationIcon = { navigationIcon = {
@@ -345,11 +345,14 @@ fun ServerConfigScreen() {
containerColor = Color.Transparent, containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent scrolledContainerColor = Color.Transparent
), ),
modifier = Modifier.hazeEffect(state = actionHazeState, style = HazeMaterials.thin()) { modifier = Modifier.hazeEffect(state = actionHazeState) {
progressive = HazeProgressive.verticalGradient( blurEffect {
startIntensity = 1f, style = topBarHazeStyle
endIntensity = 0f, progressive = HazeProgressive.verticalGradient(
) startIntensity = 1f,
endIntensity = 0f,
)
}
} }
) )
} }
@@ -47,11 +47,11 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp 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.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.ClientRequestException
@@ -110,7 +110,6 @@ private object EditProfileLazyListIndices {
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3ExpressiveApi::class,
ExperimentalFoundationApi::class, ExperimentalFoundationApi::class,
ExperimentalHazeMaterialsApi::class,
) )
@Composable @Composable
fun EditProfileScreen( fun EditProfileScreen(
@@ -340,6 +339,7 @@ fun EditProfileScreen(
} }
}, },
topBar = { topBar = {
val topBarHazeStyle = HazeMaterials.thin()
TopAppBar( TopAppBar(
title = { Text(stringResource(Res.string.profile_edit_title)) }, title = { Text(stringResource(Res.string.profile_edit_title)) },
navigationIcon = { navigationIcon = {
@@ -367,11 +367,14 @@ fun EditProfileScreen(
containerColor = Color.Transparent, containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent, scrolledContainerColor = Color.Transparent,
), ),
modifier = Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) { modifier = Modifier.hazeEffect(state = hazeState) {
progressive = HazeProgressive.verticalGradient( blurEffect {
startIntensity = 1f, style = topBarHazeStyle
endIntensity = 0f, progressive = HazeProgressive.verticalGradient(
) startIntensity = 1f,
endIntensity = 0f,
)
}
}, },
) )
}, },
@@ -37,11 +37,13 @@ import androidx.compose.ui.unit.Dp
import androidx.navigation.NavController import androidx.navigation.NavController
import com.pr0gramm3r101.components.ListItemPosition import com.pr0gramm3r101.components.ListItemPosition
import com.pr0gramm3r101.utils.SupportClipboardManager import com.pr0gramm3r101.utils.SupportClipboardManager
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState 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.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile 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.ListItem
import com.pr0gramm3r101.components.listItemPositionInGroup import com.pr0gramm3r101.components.listItemPositionInGroup
import com.pr0gramm3r101.utils.supportClipboardManagerImpl import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation
@@ -2028,7 +2028,6 @@ private fun ExpressiveProfileActionButton(
} }
} }
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
private fun ProfileFloatingBackBar( private fun ProfileFloatingBackBar(
visible: Boolean, visible: Boolean,
@@ -2039,16 +2038,20 @@ private fun ProfileFloatingBackBar(
) { ) {
if (!visible) return if (!visible) return
val backBarHazeStyle = HazeMaterials.thin()
Box(modifier = modifier.fillMaxWidth()) { Box(modifier = modifier.fillMaxWidth()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(blurHeight) .height(blurHeight)
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) { .hazeEffect(state = hazeState) {
progressive = HazeProgressive.verticalGradient( blurEffect {
startIntensity = 1f, style = backBarHazeStyle
endIntensity = 0f, progressive = HazeProgressive.verticalGradient(
) startIntensity = 1f,
endIntensity = 0f,
)
}
}, },
) )
IconButton( IconButton(
@@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.send.installOutboxForegroundDrain
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
object IosApplicationBootstrap { object IosApplicationBootstrap {
@@ -16,6 +17,7 @@ object IosApplicationBootstrap {
fun launchOnApplicationStart() { fun launchOnApplicationStart() {
if (started) return if (started) return
started = true started = true
installOutboxForegroundDrain()
scope.launch { scope.launch {
runCatching { ApiClient.loadPersistedData() } runCatching { ApiClient.loadPersistedData() }
runCatching { AttachmentTransferBootstrap.runColdStart() } runCatching { AttachmentTransferBootstrap.runColdStart() }
@@ -0,0 +1,3 @@
package ru.fromchat
actual fun keepWebSocketAliveInBackground(): Boolean = false
@@ -1,5 +1,5 @@
package ru.fromchat.api package ru.fromchat.api
actual suspend fun syncPushTokenAfterStartup() { actual suspend fun syncPushTokenAfterStartup() {
// айфон г@вно поэтому этого не будет потому что не хочу платить кучу денег за App Store // iOS: no APNs / FCM push token sync in this build.
} }
@@ -11,7 +11,7 @@ import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask import platform.Foundation.NSUserDomainMask
import platform.Foundation.create import platform.Foundation.create
import platform.Foundation.writeToFile import platform.Foundation.writeToFile
import ru.fromchat.api.db.MessageDatabaseProvider import ru.fromchat.api.local.db.store.MessageDatabaseProvider
private const val GENERATION_FILE = ".generation" private const val GENERATION_FILE = ".generation"
@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) @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.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap
@@ -23,8 +23,6 @@ import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext
import platform.UIKit.UIImage import platform.UIKit.UIImage
import platform.UIKit.UIImageOrientation import platform.UIKit.UIImageOrientation
import platform.UIKit.UIImagePNGRepresentation import platform.UIKit.UIImagePNGRepresentation
import ru.fromchat.api.local.download.decodeImageBytes
actual object PlatformDecodedBitmapCache { actual object PlatformDecodedBitmapCache {
private val cache = mutableMapOf<String, ImageBitmap>() private val cache = mutableMapOf<String, ImageBitmap>()
@@ -1,5 +1,36 @@
@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
package ru.fromchat.api.local.download package ru.fromchat.api.local.download
internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? = 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<Int, Int>? = null internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? =
runCatching {
val image = UIImage.imageWithContentsOfFile(absolutePath) ?: return null
pixelSize(image)
}.getOrNull()
internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair<Int, Int>? {
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<Int, Int>? {
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
}
@@ -2,8 +2,29 @@ package ru.fromchat.api.local.send
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch 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) { actual fun scheduleOutboxProcessing(instanceId: String) {
if (instanceId.trim().isEmpty()) return if (instanceId.trim().isEmpty()) return
MainScope().launch { MainScope().launch {
@@ -1,8 +1,12 @@
package ru.fromchat.api.local.workers package ru.fromchat.api.local.workers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.SharedFlow 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.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 object AttachmentUploadQueue {
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = AttachmentUploadNotifier.progressFlow actual val progressFlow: SharedFlow<AttachmentUploadProgress> = AttachmentUploadNotifier.progressFlow
@@ -12,6 +16,14 @@ actual object AttachmentUploadQueue {
} }
actual fun cancel(jobId: String) { 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)
}
} }
} }
@@ -2,21 +2,28 @@ package ru.fromchat.logging
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.UByteVar import kotlinx.cinterop.UByteVar
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.alloc import kotlinx.cinterop.alloc
import kotlinx.cinterop.allocArray import kotlinx.cinterop.allocArray
import kotlinx.cinterop.convert import kotlinx.cinterop.convert
import kotlinx.cinterop.memScoped import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr import kotlinx.cinterop.ptr
import kotlinx.cinterop.reinterpret import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.sizeOf
import kotlinx.cinterop.toCValues 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.Z_DEFAULT_COMPRESSION
import platform.zlib.compress2 import platform.zlib.compress2
import platform.zlib.uLongVar
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
internal actual fun gzipCompress(input: ByteArray): ByteArray { internal actual fun gzipCompress(input: ByteArray): ByteArray {
if (input.isEmpty()) { 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( val header = byteArrayOf(
@@ -50,34 +57,42 @@ internal actual fun gzipCompress(input: ByteArray): ByteArray {
} }
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
private fun deflateRaw(input: ByteArray): ByteArray = memScoped { private fun deflateRaw(input: ByteArray): ByteArray {
if (input.isEmpty()) return@memScoped ByteArray(0) if (input.isEmpty()) return ByteArray(0)
var capacity = (input.size + (input.size / 10) + 12).coerceAtLeast(64) return memScoped {
while (true) { var capacity = (input.size + (input.size / 10) + 12).coerceAtLeast(64)
val output = allocArray<UByteVar>(capacity) while (true) {
val source = input.toUByteArray().toCValues() val output = allocArray<UByteVar>(capacity)
val sourceLength = input.size.convert<platform.zlib.uLong>() val source = input.toUByteArray().toCValues()
val destLength = alloc<platform.zlib.uLongVar>() val sourceLength = input.size.convert<platform.zlib.uLong>()
destLength.value = capacity.convert() val destLength = alloc<uLongVar>()
destLength.value = capacity.convert()
val status = compress2( val status = compress2(
output, output,
destLength.ptr, destLength.ptr,
source.ptr.reinterpret(), source.ptr.reinterpret(),
sourceLength, sourceLength,
Z_DEFAULT_COMPRESSION, Z_DEFAULT_COMPRESSION,
) )
if (status == 0) { if (status == 0) {
val size = destLength.value.toInt() 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())
capacity *= 2 }
if (capacity > input.size * 20) { return@memScoped result
return@memScoped input }
capacity *= 2
if (capacity > input.size * 20) {
return@memScoped input
}
} }
@Suppress("UNREACHABLE_CODE")
ByteArray(0)
} }
} }
@@ -6,28 +6,30 @@ import kotlinx.cinterop.addressOf
import kotlinx.cinterop.alloc import kotlinx.cinterop.alloc
import kotlinx.cinterop.allocArray import kotlinx.cinterop.allocArray
import kotlinx.cinterop.convert import kotlinx.cinterop.convert
import kotlinx.cinterop.get
import kotlinx.cinterop.memScoped import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr import kotlinx.cinterop.ptr
import kotlinx.cinterop.reinterpret import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.toCValues import kotlinx.cinterop.toCValues
import kotlinx.cinterop.usePinned import kotlinx.cinterop.usePinned
import kotlinx.cinterop.value
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import platform.Foundation.NSData import platform.Foundation.NSData
import platform.Foundation.NSFileManager import platform.Foundation.NSFileManager
import platform.Foundation.NSString
import platform.Foundation.NSUTF8StringEncoding
import platform.Foundation.create import platform.Foundation.create
import platform.Foundation.dataWithContentsOfFile import platform.Foundation.dataWithContentsOfFile
import platform.Foundation.writeToFile import platform.Foundation.writeToFile
import platform.posix.memcpy import platform.posix.memcpy
import platform.zlib.uLongVar
import platform.zlib.uncompress
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
internal actual object LogFileOps { internal actual object LogFileOps {
actual fun readText(path: String): String { actual fun readText(path: String): String {
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return "" 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 { actual fun readBytes(path: String): ByteArray {
@@ -36,53 +38,60 @@ internal actual object LogFileOps {
return raw.toByteArray() return raw.toByteArray()
} }
actual suspend fun gzipFile(sourcePath: String, destinationPath: String) = withContext(Dispatchers.Default) { actual suspend fun gzipFile(sourcePath: String, destinationPath: String) {
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@withContext withContext(Dispatchers.Default) {
val raw = NSData.dataWithContentsOfFile(sourcePath) ?: return@withContext if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@withContext
val bytes = raw.toByteArray() val raw = NSData.dataWithContentsOfFile(sourcePath) ?: return@withContext
val gzipped = gzipCompress(bytes) val gzipped = gzipCompress(raw.toByteArray())
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath) val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null) NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
NSData.create(bytes = gzipped, length = gzipped.size.toULong()) writeBytes(destinationPath, gzipped)
.writeToFile(destinationPath, true) NSFileManager.defaultManager.removeItemAtPath(sourcePath, null)
NSFileManager.defaultManager.removeItemAtPath(sourcePath, null) }
} }
actual suspend fun readGzipText(path: String, onProgress: (Float) -> Unit): String = actual suspend fun readGzipText(path: String, onProgress: (Float) -> Unit): String =
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val bytes = gunzipToByteArray(path, onProgress) gunzipToByteArray(path, onProgress).decodeToString()
bytes.decodeToString()
} }
actual suspend fun gunzipToFile( actual suspend fun gunzipToFile(
sourcePath: String, sourcePath: String,
destinationPath: String, destinationPath: String,
onProgress: (Float) -> Unit, onProgress: (Float) -> Unit,
) = withContext(Dispatchers.Default) { ) {
val bytes = gunzipToByteArray(sourcePath, onProgress) withContext(Dispatchers.Default) {
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath) val bytes = gunzipToByteArray(sourcePath, onProgress)
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null) val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
NSData.create(bytes = bytes, length = bytes.size.toULong()) NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
.writeToFile(destinationPath, true) writeBytes(destinationPath, bytes)
}
} }
actual suspend fun zipFiles( actual suspend fun zipFiles(
entries: List<Pair<String, String>>, entries: List<Pair<String, String>>,
destinationPath: String, destinationPath: String,
) = withContext(Dispatchers.Default) { ) {
if (entries.isEmpty()) return@withContext withContext(Dispatchers.Default) {
val zipEntries = entries.mapNotNull { (entryName, sourcePath) -> if (entries.isEmpty()) return@withContext
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@mapNotNull null val zipEntries = entries.mapNotNull { (entryName, sourcePath) ->
ZipFileEntry(entryName, readBytes(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 { private fun gunzipToByteArray(path: String, onProgress: (Float) -> Unit): ByteArray {
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) { if (!NSFileManager.defaultManager.fileExistsAtPath(path)) {
onProgress(1f) onProgress(1f)
@@ -103,17 +112,16 @@ internal actual object LogFileOps {
return inflated return inflated
} }
@OptIn(ExperimentalForeignApi::class)
private fun inflateGzipPayload(deflated: ByteArray): ByteArray = memScoped { private fun inflateGzipPayload(deflated: ByteArray): ByteArray = memScoped {
if (deflated.isEmpty()) return@memScoped ByteArray(0) if (deflated.isEmpty()) return@memScoped ByteArray(0)
var capacity = (deflated.size * 4).coerceAtLeast(256) var capacity = (deflated.size * 4).coerceAtLeast(256)
while (capacity <= deflated.size * 32) { while (capacity <= deflated.size * 32) {
val output = allocArray<UByteVar>(capacity) val output = allocArray<UByteVar>(capacity)
val destLength = alloc<platform.zlib.uLongVar>() val destLength = alloc<uLongVar>()
destLength.value = capacity.convert<platform.zlib.uLong>() destLength.value = capacity.convert()
val source = deflated.toUByteArray().toCValues() val source = deflated.toUByteArray().toCValues()
val status = platform.zlib.uncompress( val status = uncompress(
output, output,
destLength.ptr, destLength.ptr,
source.ptr.reinterpret(), source.ptr.reinterpret(),
@@ -121,14 +129,17 @@ internal actual object LogFileOps {
) )
if (status == 0) { if (status == 0) {
val size = destLength.value.toInt() 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 capacity *= 2
} }
ByteArray(0) ByteArray(0)
} }
@OptIn(ExperimentalForeignApi::class)
private fun NSData.toByteArray(): ByteArray { private fun NSData.toByteArray(): ByteArray {
val length = this.length.toInt() val length = this.length.toInt()
if (length == 0) return ByteArray(0) if (length == 0) return ByteArray(0)
@@ -1,10 +1,67 @@
@file:OptIn(ExperimentalForeignApi::class)
package ru.fromchat.ui.auth.captcha 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.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.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 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 @Composable
actual fun SmartCaptchaWebView( actual fun SmartCaptchaWebView(
sitekey: String, sitekey: String,
@@ -16,12 +73,186 @@ actual fun SmartCaptchaWebView(
onChallengeHidden: () -> Unit, onChallengeHidden: () -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
) { ) {
LaunchedEffect(sitekey) { val onTokenState = rememberUpdatedState(onToken)
Logger.w( val onReadyState = rememberUpdatedState(onReady)
SmartCaptchaLog.TAG, val onChallengeVisibleState = rememberUpdatedState(onChallengeVisible)
"iOS stub: captcha unavailable sitekey=${SmartCaptchaLog.redactKey(sitekey)} " + val onChallengeHiddenState = rememberUpdatedState(onChallengeHidden)
"languageTag=$languageTag", val onErrorState = rememberUpdatedState(onError)
) val background = MaterialTheme.colorScheme.surfaceContainer
onError("Captcha is not available on this platform yet.") 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<WKWebView>(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(),
)
@@ -1,9 +1,108 @@
@file:OptIn(ExperimentalForeignApi::class)
package ru.fromchat.ui.auth.yandex 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.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect 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.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 @Composable
actual fun YandexOAuthWebView( actual fun YandexOAuthWebView(
authorizeUrl: String, authorizeUrl: String,
@@ -17,7 +116,287 @@ actual fun YandexOAuthWebView(
onError: (String) -> Unit, onError: (String) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
) { ) {
LaunchedEffect(authorizeUrl) { var canGoBack by remember { mutableStateOf(false) }
onError("Yandex sign-in is not available on this platform yet.") 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<WKWebView>(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<NSHTTPCookie>()?.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<Any?, *> = 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(),
)
@@ -1,11 +1,43 @@
@file:OptIn(ExperimentalForeignApi::class)
package ru.fromchat.ui.calls package ru.fromchat.ui.calls
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize 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.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.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 import ru.fromchat.api.calls.LiveKitConnectSession
private const val TAG = "CallMediaLayer"
private const val HANDLER_NAME = "FromChatCall"
@Composable @Composable
actual fun CallMediaLayer( actual fun CallMediaLayer(
connect: LiveKitConnectSession?, connect: LiveKitConnectSession?,
@@ -13,5 +45,182 @@ actual fun CallMediaLayer(
showInCallControls: Boolean, showInCallControls: Boolean,
modifier: Modifier, 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<WKWebView>(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),
)
}
}
} }
@@ -1,3 +1,7 @@
package ru.fromchat.ui.calls 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
@@ -0,0 +1,3 @@
package ru.fromchat.ui.calls
internal actual fun notifyIncomingCall(title: String, body: String) = Unit
@@ -1,21 +1,218 @@
@file:OptIn(ExperimentalForeignApi::class)
package ru.fromchat.ui.chat.utils package ru.fromchat.ui.chat.utils
import androidx.compose.runtime.Composable 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 { 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 @Composable
actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit { actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit {
return { /* Phase 4: PHPickerViewController */ } var activeDelegate by remember { mutableStateOf<ImagePickerDelegate?>(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 @Composable
actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit { actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
return { /* Phase 4: UIDocumentPickerViewController */ } var activeDelegate by remember { mutableStateOf<FilePickerDelegate?>(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<Int, Int>? = null actual suspend fun getImageDimensions(uri: String): Pair<Int, Int>? = 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<Int, Int>? {
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<String>().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<String>) -> 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<PHPickerResult>()
if (results.isEmpty()) {
finish(emptyList())
return
}
val collected = arrayOfNulls<String>(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<String>) {
if (finished) return
finished = true
onFinished(uris)
}
}
private class FilePickerDelegate(
private val onFinished: (List<String>) -> Unit,
) : NSObject(), UIDocumentPickerDelegateProtocol {
private var finished = false
private fun finish(uris: List<String>) {
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())
}
}
@@ -1,4 +1,4 @@
package ru.fromchat.ui package ru.fromchat.ui.components
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -14,8 +14,8 @@ actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
@Composable @Composable
actual fun rememberHapticFeedbackInternal(): (Int) -> Unit { actual fun rememberHapticFeedbackInternal(): (Int) -> Unit =
return remember { remember {
{ ordinal -> { ordinal ->
val style: UIImpactFeedbackStyle = when (ordinal) { val style: UIImpactFeedbackStyle = when (ordinal) {
HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
@@ -23,8 +23,6 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
HapticFeedbackEvent.SelectionModeEntered.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium HapticFeedbackEvent.SelectionModeEntered.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight
} }
val generator = UIImpactFeedbackGenerator(style) UIImpactFeedbackGenerator(style).impactOccurred()
generator.impactOccurred()
} }
} }
}
@@ -1,8 +0,0 @@
package ru.fromchat.ui.debug
import androidx.compose.runtime.Composable
@Composable
actual fun DebugApiScreen() {
DebugApiScreenContent()
}
@@ -11,6 +11,6 @@ actual fun openAppNotificationSettings(): Boolean {
return UIApplication.sharedApplication.openURL(url) return UIApplication.sharedApplication.openURL(url)
} }
actual fun areAppNotificationsEnabled(): Boolean { actual fun areAppNotificationsEnabled(): Boolean = false
return true
} actual fun arePushNotificationsSupported(): Boolean = false
@@ -1,15 +1,44 @@
@file:OptIn(ExperimentalForeignApi::class)
package ru.fromchat.utils package ru.fromchat.utils
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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 { actual object NetworkConnectivity {
private val _isOnline = MutableStateFlow(true) private val _isOnline = MutableStateFlow(true)
actual val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow() actual val isOnline: StateFlow<Boolean> = _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() { 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)
} }
} }
@@ -0,0 +1,3 @@
package ru.fromchat
actual fun keepWebSocketAliveInBackground(): Boolean = true
@@ -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()
}
}
@@ -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)
}
}
@@ -0,0 +1,3 @@
package ru.fromchat.api
actual suspend fun syncPushTokenAfterStartup() = Unit
@@ -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
@@ -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)
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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<TransportCiphertext, ByteArray> = 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
}
}
@@ -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,
)
}
}
}
@@ -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<ByteArray, ByteArray> = 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)
}
@@ -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)
}
@@ -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
@@ -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()
}
}
@@ -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()
}
}
@@ -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")
}
}
@@ -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)
}
@@ -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()
}
}
@@ -0,0 +1,6 @@
package ru.fromchat.api.local.db
internal actual fun <T> withMessageDatabaseLock(block: () -> T): T =
synchronized(MessageDatabaseLock) { block() }
private object MessageDatabaseLock
@@ -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
}
@@ -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
}
@@ -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)
}
@@ -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
}
}
@@ -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<String, ImageBitmap>()
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()
}
@@ -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<Int, Int>? =
runCatching {
val image = ImageIO.read(File(absolutePath)) ?: return null
image.width to image.height
}.getOrNull()
internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair<Int, Int>? =
runCatching {
val image = ImageIO.read(ByteArrayInputStream(data)) ?: return null
image.width to image.height
}.getOrNull()
@@ -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)
}
}
}
}
@@ -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)
}
}
}
@@ -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
@@ -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
}
@@ -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<AttachmentUploadProgress> = AttachmentUploadNotifier.progressFlow
actual fun enqueue(job: AttachmentUploadJob) {
scheduleOutboxProcessing(CacheContext.activeInstanceId.value.trim())
}
actual fun cancel(jobId: String) = Unit
}
@@ -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") { " <string>${it.trim('"').xmlEscape()}</string>" }
plist.parentFile?.mkdirs()
plist.writeText(
"""
|<?xml version="1.0" encoding="UTF-8"?>
|<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|<plist version="1.0">
|<dict>
| <key>Label</key>
| <string>ru.fromchat.desktop</string>
| <key>ProgramArguments</key>
| <array>
|$programArgs
| </array>
| <key>RunAtLoad</key>
| <true/>
|</dict>
|</plist>
""".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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
}
@@ -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<String>(
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val links: SharedFlow<String> = _links.asSharedFlow()
private val _oauthRedirects = MutableSharedFlow<String>(
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val oauthRedirects: SharedFlow<String> = _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) + "..."
}
@@ -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)
}
}
}
@@ -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) },
)
}
@@ -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()
}
@@ -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<Pair<String, String>>,
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()
}
}
@@ -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)")
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More