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"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
<bytecodeTargetLevel target="17" />
</component>
</project>
+1
View File
@@ -12,6 +12,7 @@
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/app/android" />
<option value="$PROJECT_DIR$/app/desktop" />
<option value="$PROJECT_DIR$/app/shared" />
<option value="$PROJECT_DIR$/utils" />
<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">
<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" />
</component>
<component name="ProjectType">
+11 -10
View File
@@ -2,14 +2,14 @@ Read in other languages: [Русский](./README.md)
# FromChat
FromChat is a 100% free and open-source messenger. This repository is the cross-platform client (Android + iOS; iOS is not ready yet).
FromChat is a 100% free and open-source messenger. This repository is the cross-platform client (Android, Desktop via Compose Multiplatform, and iOS).
[📥 Download](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram Channel](https://t.me/fromchat_ch) • [🖥️ Server](https://github.com/fromchat-messenger/backend)
## ✨ Features
- **Voice and video calls** — LiveKit
- **Screen sharing** during calls
- **Screen sharing** during calls (Android)
- **Public chat** — server-wide community
- **Direct messages** — legal encryption scheme; server-side E2EE planned
- **Device management** — active sessions
@@ -18,15 +18,16 @@ FromChat is a 100% free and open-source messenger. This repository is the cross-
## 📊 Client Comparison
⚠️ **iOS is temporarily not supported** (Apple constraints and development cost). It will ship later.
| Feature | Android | Desktop | Web | iOS |
| --- | --- | --- | --- | --- |
| **Messaging and profiles** | ✅ | ✅ | ✅ | ✅ |
| **Voice/video calls** | ✅ | ✅ | ✅ | ✅ |
| **Screen sharing** | ✅ | ❌ | ✅ | ❌ |
| **Push when backgrounded** | ✅ (FCM) | ✅ (persistent WS + tray) | ✅ | ❌ (WS while open) |
| **Message reactions** | ❌ | ❌ | ✅ | ❌ |
| **Rich attachment support** | ✅ | ✅ | ❌ | ✅ |
| Feature | Android | Web | iOS |
| --- | --- | --- | --- |
| **Messaging and profiles** | ✅ | ✅ | ❌ |
| **Voice/video calls** | ✅ | ✅ | ❌ |
| **Screen sharing** | ✅ | ✅ | ❌ |
| **Message reactions** | ❌ | ✅ | ❌ |
| **Rich attachment support** | ✅ | ❌ | ❌ |
Desktop replaces the former Electron client in the Web repo. Run with `./gradlew :app:desktop:run`.
---
+11 -10
View File
@@ -2,14 +2,14 @@
# FromChat
FromChat — 100% бесплатный и открытый мессенджер. В этом репозитории — кроссплатформенный клиент (Android + iOS; iOS пока не готов).
FromChat — 100% бесплатный и открытый мессенджер. В этом репозитории — кроссплатформенный клиент (Android, Desktop на Compose Multiplatform и iOS).
[📥 Скачать](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram-канал](https://t.me/fromchat_ch) • [🖥️ Сервер](https://github.com/fromchat-messenger/backend)
## ✨ Возможности
- **Голосовые и видеозвонки** — LiveKit
- **Демонстрация экрана** во время звонков
- **Демонстрация экрана** во время звонков (Android)
- **Общий чат** — сообщество пользователей сервера
- **Личные сообщения** — легальная схема шифрования; E2EE на сервере планируется
- **Управление устройствами** — активные сеансы
@@ -18,15 +18,16 @@ FromChat — 100% бесплатный и открытый мессенджер.
## 📊 Сравнение клиентов
⚠️ **iOS временно не поддерживается** (ограничения Apple и объём работы). Клиент выйдет позже.
| Возможность | Android | Desktop | Web | iOS |
| --- | --- | --- | --- | --- |
| **Обмен сообщениями и профили** | ✅ | ✅ | ✅ | ✅ |
| **Голосовые/видеозвонки** | ✅ | ✅ | ✅ | ✅ |
| **Демонстрация экрана** | ✅ | ❌ | ✅ | ❌ |
| **Push в фоне** | ✅ (FCM) | ✅ (постоянный WS + tray) | ✅ | ❌ (WS пока открыто) |
| **Реакции на сообщения** | ❌ | ❌ | ✅ | ❌ |
| **Расширенная поддержка вложений** | ✅ | ✅ | ❌ | ✅ |
| Возможность | Android | Web | iOS |
| --- | --- | --- | --- |
| **Обмен сообщениями и профили** | ✅ | ✅ | ❌ |
| **Голосовые/видеозвонки** | ✅ | ✅ | ❌ |
| **Демонстрация экрана** | ✅ | ✅ | ❌ |
| **Реакции на сообщения** | ❌ | ✅ | ❌ |
| **Расширенная поддержка вложений** | ✅ | ❌ | ❌ |
Desktop заменяет бывший Electron-клиент в репозитории Web. Запуск: `./gradlew :app:desktop:run`.
---
+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>
<true/>
</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>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
+36 -2
View File
@@ -75,6 +75,8 @@ kotlin {
freeCompilerArgs.addAll("-Xexpect-actual-classes")
}
jvm()
listOf(
iosArm64(),
iosSimulatorArm64(),
@@ -107,8 +109,8 @@ kotlin {
implementation(libs.navigation.compose)
implementation(libs.compose.materialIconsExtended)
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.androidx.core.ktx)
implementation(libs.haze.blur)
implementation(libs.haze.blur.materials)
implementation(libs.androidx.lifecycle.runtime.compose)
// Serialization
@@ -142,6 +144,7 @@ kotlin {
}
androidMain.dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.markdown.renderer.m3)
implementation(libs.bouncycastle.bcprov)
implementation(libs.androidx.exifinterface)
@@ -164,6 +167,37 @@ kotlin {
implementation(libs.multiplatform.crypto.libsodium.bindings)
implementation(libs.sqldelight.driver.native)
}
jvmMain.dependencies {
implementation(libs.jetbrains.kotlinx.io.bytestring)
implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.ktor.client.cio)
implementation(libs.multiplatform.crypto.libsodium.bindings)
implementation(libs.sqldelight.driver.sqlite)
implementation(libs.bouncycastle.bcprov)
// OpenJFX publishes empty jars without a classifier; declare every module
// with the host classifier and exclude transitive stubs.
val javafxVersion = libs.versions.openjfx.get()
val javafxClassifier = openjfxClassifier()
listOf("base", "graphics", "controls", "media", "web", "swing").forEach { module ->
implementation("org.openjfx:javafx-$module:$javafxVersion:$javafxClassifier") {
exclude(group = "org.openjfx")
}
}
}
}
}
private fun openjfxClassifier(): String {
val os = System.getProperty("os.name").lowercase()
val arch = System.getProperty("os.arch").lowercase()
val isArm = arch == "aarch64" || arch == "arm64"
return when {
os.contains("mac") && isArm -> "mac-aarch64"
os.contains("mac") -> "mac"
os.contains("win") -> "win"
isArm -> "linux-aarch64"
else -> "linux"
}
}
@@ -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.content.ContextCompat
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import io.livekit.android.RoomOptions
import io.livekit.android.compose.local.RoomScope
@@ -275,7 +275,6 @@ private fun CallSlotSharedVideo(
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
actual fun CallMediaLayer(
connect: LiveKitConnectSession?,
@@ -551,7 +550,6 @@ actual fun CallMediaLayer(
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun CallRoomContent(
room: Room,
@@ -651,7 +649,7 @@ private fun CallRoomContent(
val participants = rememberParticipants(room).value
val local = room.localParticipant
val remote: Participant? = participants.firstOrNull { it != local }
val hazeState = rememberHazeState(blurEnabled = showInCallControls)
val hazeState = rememberHazeState()
Box(
modifier = modifier
.fillMaxSize()
@@ -1179,7 +1177,7 @@ private fun CallPreviewCluster(
}
}
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalHazeMaterialsApi::class)
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun PreviewTile(
room: Room,
@@ -1198,7 +1196,7 @@ private fun PreviewTile(
val tileShape = RoundedCornerShape(16.dp)
// Only blur the *tile content* when the track isn't available yet.
// Do not blur the whole call scene; otherwise the opponent video stays blurred.
val hazeState = rememberHazeState(blurEnabled = ref == null || !showVideo)
val hazeState = rememberHazeState()
Box(
modifier = Modifier
.size(104.dp, 138.dp)
@@ -1223,11 +1221,16 @@ private fun PreviewTile(
innerClipShape = tileShape,
)
} else {
val placeholderHazeStyle = HazeMaterials.thick()
Box(
Modifier
.fillMaxSize()
// Placeholder blur only for missing/disabled track.
.hazeEffect(state = hazeState, style = HazeMaterials.thick())
.hazeEffect(state = hazeState) {
blurEffect {
style = placeholderHazeStyle
}
}
.background(MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.55f)),
)
}
@@ -1246,7 +1249,6 @@ private fun PreviewTile(
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun CallInlineControlBar(
room: Room,
@@ -1327,11 +1329,16 @@ private fun CallInlineControlBar(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val controlsHazeStyle = HazeMaterials.thick()
Box(
modifier = Modifier
.fillMaxWidth(0.86f)
.clip(RoundedCornerShape(28.dp))
.hazeEffect(state = hazeState, style = HazeMaterials.thick())
.hazeEffect(state = hazeState) {
blurEffect {
style = controlsHazeStyle
}
}
.background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.48f)),
) {
Row(
@@ -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 {
true
}
}
}
actual fun arePushNotificationsSupported(): Boolean = true
@@ -59,6 +59,7 @@
<string name="auth_captcha_body">Подтвердите, что вы человек, чтобы продолжить создание аккаунта.</string>
<string name="auth_captcha_failed">Не удалось пройти проверку. Попробуйте ещё раз.</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_failed">Вход через Яндекс ID отменён или не удался.</string>
<string name="chats">Чаты</string>
@@ -324,6 +325,7 @@
<string name="settings_notifications_permission_required">Откройте системные настройки и разрешите уведомления.</string>
<string name="settings_push_notifications">Push-уведомления</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_empty">Нет активных сессий</string>
@@ -66,6 +66,7 @@
<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_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_failed">Yandex sign-in was cancelled or failed.</string>
<!-- Main Screen -->
@@ -353,6 +354,7 @@
<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_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_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.jsonPrimitive
import ru.fromchat.AppForeground
import ru.fromchat.keepWebSocketAliveInBackground
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.UpdateSyncManager
@@ -117,6 +118,7 @@ object WebSocketManager {
}
private suspend fun awaitForeground() {
if (keepWebSocketAliveInBackground()) return
if (AppForeground.isInForeground.value) return
AppForeground.isInForeground.first { it }
}
@@ -369,7 +371,7 @@ object WebSocketManager {
}
fun onNetworkAvailable() {
if (!AppForeground.isInForeground.value) return
if (!keepWebSocketAliveInBackground() && !AppForeground.isInForeground.value) return
val now = Clock.System.now().toEpochMilliseconds()
val prev = lastOnNetworkAvailableWallMs
@@ -393,7 +395,7 @@ object WebSocketManager {
* Restarts the reconnect loop immediately instead of waiting out exponential backoff.
*/
fun onServerLikelyReachable() {
if (!AppForeground.isInForeground.value) return
if (!keepWebSocketAliveInBackground() && !AppForeground.isInForeground.value) return
if (session != null) return
if (ApiClient.token.isNullOrEmpty()) return
@@ -30,36 +30,25 @@ object PublicChatProfileCache {
*/
fun hydrateFromDiskImmediate(instanceId: String): PublicChatProfile? {
val id = instanceId.trim()
synchronized(this) {
if (id.isEmpty()) {
return _profile.value
}
if (loadedInstanceId == id && isCompleteProfile(_profile.value)) {
return _profile.value
}
val previousInstanceId = loadedInstanceId
loadedInstanceId = id
val disk = PublicChatProfileCacheStore.getImmediate(id)
_profile.value = when {
disk != null -> disk
previousInstanceId.isEmpty() -> _profile.value
previousInstanceId != id -> null
else -> _profile.value
}
return _profile.value
if (id.isEmpty()) return _profile.value
if (loadedInstanceId == id && isCompleteProfile(_profile.value)) return _profile.value
val previousInstanceId = loadedInstanceId
loadedInstanceId = id
val disk = PublicChatProfileCacheStore.getImmediate(id)
_profile.value = when {
disk != null -> disk
previousInstanceId.isEmpty() -> _profile.value
previousInstanceId != id -> null
else -> _profile.value
}
return _profile.value
}
fun put(profile: PublicChatProfile) {
val instanceId: String
synchronized(this) {
_profile.value = profile
instanceId = resolvedInstanceId()
if (instanceId.isNotEmpty()) {
loadedInstanceId = instanceId
}
}
_profile.value = profile
val instanceId = resolvedInstanceId()
if (instanceId.isNotEmpty()) {
loadedInstanceId = instanceId
runCatching { PublicChatProfileCacheStore.putImmediate(instanceId, profile) }
}
}
@@ -73,10 +62,8 @@ object PublicChatProfileCache {
}
suspend fun clear() {
synchronized(this) {
_profile.value = null
loadedInstanceId = ""
}
_profile.value = null
loadedInstanceId = ""
}
private fun resolvedInstanceId(): String =
@@ -54,6 +54,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.ToggleNavScrimEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
@@ -101,7 +102,7 @@ fun DocumentScreen(
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val listState = rememberLazyListState()
val hazeState = rememberHazeState(blurEnabled = true)
val hazeState = rememberHazeState()
LaunchedEffect(type) {
listState.scrollToItem(0)
@@ -116,6 +117,7 @@ fun DocumentScreen(
contentColor = MaterialTheme.colorScheme.onSurface,
contentWindowInsets = WindowInsets.navigationBars,
topBar = {
val topBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
MediumTopAppBar(
title = {
Text(
@@ -141,10 +143,11 @@ fun DocumentScreen(
),
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
.hazeEffect(
state = hazeState,
style = rememberChatSurfaceContainerHazeStyle(),
),
.hazeEffect(state = hazeState) {
blurEffect {
style = topBarHazeStyle
}
},
)
},
) { innerPadding ->
@@ -12,6 +12,7 @@ import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.coroutines.runBlocking
import kotlin.time.Clock
import kotlin.time.Instant
@@ -52,8 +53,6 @@ object AppLogStore {
private var loadedFromDisk = false
private var nextEntryId = 0L
private val writeLock = Any()
fun record(
level: AppLogLevel,
tag: String,
@@ -68,8 +67,14 @@ object AppLogStore {
message = message,
stackTrace = throwable?.stackTraceToString(),
)
synchronized(writeLock) {
appendEntry(entry)
if (mutex.tryLock()) {
try {
appendEntry(entry)
} finally {
mutex.unlock()
}
} else {
runBlocking { mutex.withLock { appendEntry(entry) } }
}
}
@@ -11,9 +11,13 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -24,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NamedNavArgument
@@ -33,6 +38,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import coil3.ImageLoader
@@ -41,8 +47,11 @@ import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.svg.SvgDecoder
import ru.fromchat.api.ApiClient
import com.pr0gramm3r101.utils.LocalSystemBarsVisibility
import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.rememberSystemBarsController
import com.pr0gramm3r101.utils.widthSizeClass
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
@@ -55,6 +64,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.AppForeground
import ru.fromchat.Logger
import ru.fromchat.keepWebSocketAliveInBackground
import ru.fromchat.api.DeferredStartupNetwork
import ru.fromchat.api.ProfileUpdateSync
import ru.fromchat.api.PublicChatProfileSync
@@ -116,6 +126,10 @@ import ru.fromchat.utils.NetworkConnectivity
val LocalNavController = compositionLocalOf<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 fun rootNavEnterTransition(): EnterTransition =
@@ -281,9 +295,14 @@ fun App(
}
// Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]).
// Desktop keeps the socket alive while the process runs (tray / hidden window).
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
fun syncForeground() {
if (keepWebSocketAliveInBackground()) {
AppForeground.setForeground(true)
return
}
AppForeground.setForeground(
lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
)
@@ -301,7 +320,11 @@ fun App(
}
}
}
Lifecycle.Event.ON_STOP -> AppForeground.setForeground(false)
Lifecycle.Event.ON_STOP -> {
if (!keepWebSocketAliveInBackground()) {
AppForeground.setForeground(false)
}
}
else -> {}
}
}
@@ -413,16 +436,37 @@ fun App(
LocalSystemBarsVisibility provides rememberSystemBarsController()
) {
if (startDestination != null) {
val currentEntry by navController.currentBackStackEntryAsState()
val showConversationListDetail =
currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.EXPANDED &&
isConversationChatRoute(currentEntry?.destination?.route)
ScreenSurface {
Box(Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = startDestination!!,
enterTransition = { rootNavEnterTransition() },
exitTransition = { rootNavExitTransition() },
popEnterTransition = { rootNavPopEnterTransition() },
popExitTransition = { rootNavPopExitTransition() },
) {
Row(Modifier.fillMaxSize()) {
if (showConversationListDetail) {
Box(
modifier = Modifier
.width(360.dp)
.fillMaxHeight(),
) {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
snackbarHostState = profileLookupSnackbarHostState,
listPaneOnly = true,
)
}
VerticalDivider()
}
Box(Modifier.weight(1f).fillMaxSize()) {
NavHost(
navController = navController,
startDestination = startDestination!!,
enterTransition = { rootNavEnterTransition() },
exitTransition = { rootNavExitTransition() },
popEnterTransition = { rootNavPopEnterTransition() },
popExitTransition = { rootNavPopExitTransition() },
) {
composable("serverConfig") {
ServerConfigScreen()
}
@@ -574,7 +618,7 @@ fun App(
),
) { entry ->
val focusField = EditProfileFocusField.fromArg(
entry.arguments?.getString(ProfileRoutes.ARG_FOCUS),
entry.savedStateHandle.get<String>(ProfileRoutes.ARG_FOCUS),
)
EditProfileScreen(
onBack = { navController.navigateUp() },
@@ -733,8 +777,10 @@ fun App(
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
import androidx.compose.animation.AnimatedContent
@@ -68,10 +66,10 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.pr0gramm3r101.utils.crypto.Base64
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -122,7 +120,6 @@ internal fun isImageFilename(name: String): Boolean =
name.endsWith(".png", true) || name.endsWith(".jpg", true) ||
name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true)
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun AttachmentPreview(
file: DmFile?,
@@ -313,7 +310,7 @@ fun AttachmentPreview(
}
}
@OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ChatImageTileContent(
messageId: Int,
@@ -458,7 +455,8 @@ private fun ChatImageTileContent(
val imageContentScale = ContentScale.Crop
val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f)
val thumbHazeState = rememberHazeState(blurEnabled = true)
val thumbHazeState = rememberHazeState()
val thumbOverlayHazeStyle = HazeMaterials.thin()
LaunchedEffect(messageId, fileIndex, cacheClientId) {
DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri ->
@@ -793,10 +791,11 @@ private fun ChatImageTileContent(
Box(
modifier = Modifier
.fillMaxSize()
.hazeEffect(
state = thumbHazeState,
style = HazeMaterials.thin(),
),
.hazeEffect(state = thumbHazeState) {
blurEffect {
style = thumbOverlayHazeStyle
}
},
)
}
}
@@ -918,7 +917,6 @@ private fun ChatImageTileContent(
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun DownloadCancelledImageOverlay(
isAuthor: Boolean,
@@ -926,9 +924,14 @@ private fun DownloadCancelledImageOverlay(
modifier: Modifier = Modifier,
) {
val scrim = MaterialTheme.colorScheme.scrim.copy(alpha = 0.38f)
val overlayHazeStyle = HazeMaterials.thin()
Box(
modifier = modifier
.hazeEffect(style = HazeMaterials.thin())
.hazeEffect {
blurEffect {
style = overlayHazeStyle
}
}
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
@@ -958,7 +961,6 @@ private fun DownloadCancelledImageOverlay(
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun UploadingImageOverlay(
model: String?,
@@ -969,12 +971,17 @@ private fun UploadingImageOverlay(
onCancelUpload: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val overlayHazeStyle = HazeMaterials.thin()
Box(modifier = modifier) {
Box(
modifier = Modifier
.matchParentSize()
.clip(clipShape)
.hazeEffect(style = HazeMaterials.thin())
.hazeEffect {
blurEffect {
style = overlayHazeStyle
}
}
) {
when {
previewBitmap != null -> {
@@ -1084,7 +1091,6 @@ internal fun ExpressiveUploadIndicator(
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun PendingImageContent(
uri: String,
@@ -1260,10 +1266,15 @@ private fun CorruptedImagePlaceholder(
modifier: Modifier = Modifier,
clipShape: RoundedCornerShape = attachmentImageCornerShape(isAuthor = false),
) {
val overlayHazeStyle = HazeMaterials.thin()
Box(
modifier = modifier
.clip(clipShape)
.hazeEffect(style = HazeMaterials.thin()),
.hazeEffect {
blurEffect {
style = overlayHazeStyle
}
},
contentAlignment = Alignment.Center,
) {
Icon(
@@ -72,6 +72,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -244,6 +245,7 @@ fun ChatInput(
isReadOnly: Boolean = false,
onReadOnlyMessageClick: () -> Unit = {},
snackbarHostState: SnackbarHostState? = null,
hazeBlurEnabled: Boolean = true,
) {
val composerHazeStyle = rememberChatSurfaceContainerHazeStyle()
val scope = rememberCoroutineScope()
@@ -313,7 +315,12 @@ fun ChatInput(
modifier = Modifier
.fillMaxWidth()
.clip(pillShape)
.hazeEffect(state = hazeState, style = composerHazeStyle)
.hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
style = composerHazeStyle
}
}
.clickable(enabled = true) { onReadOnlyMessageClick() },
) {
Text(
@@ -344,7 +351,12 @@ fun ChatInput(
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer, pillShape)
.clip(pillShape)
.hazeEffect(state = hazeState, style = composerHazeStyle),
.hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
style = composerHazeStyle
}
},
) {
AnimatedPreviewBar(replyTo) { reply ->
val replySubtitle = if (reply.isContentCorrupted) {
@@ -550,10 +562,14 @@ fun ChatInput(
modifier = Modifier
.fillMaxSize()
.background(actionBackground)
.hazeEffect(state = hazeState, style = composerHazeStyle) {
// Keep the node in the tree in both directions; fading alpha avoids the
// frosted layer popping on over the color when send -> voice.
alpha = hazeOverlayAlpha
.hazeEffect(state = hazeState) {
blurEffect {
// Keep the node in the tree in both directions; fading alpha avoids the
// frosted layer popping on over the color when send -> voice.
blurEnabled = hazeBlurEnabled
style = composerHazeStyle
alpha = hazeOverlayAlpha
}
},
)
AnimatedContent(
@@ -79,11 +79,11 @@ import ru.fromchat.utils.rememberRegistrationDateFormatStrings
import com.pr0gramm3r101.utils.resetFocus
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.blur.HazeProgressive
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
@@ -157,7 +157,7 @@ import ru.fromchat.utils.rememberLastSeenFormatStrings
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
panel: ChatPanel,
@@ -426,9 +426,8 @@ fun ChatScreen(
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val profileUserId = panelState.profileUserId
val hazeState = rememberHazeState(
blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
)
val hazeState = rememberHazeState()
val hazeBlurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
val statusMap by UserStatusStore.status.collectAsState()
@@ -816,16 +815,21 @@ fun ChatScreen(
// floating header Box only; avoids extra top inset on the fade / list and duplicate “padding”.
contentWindowInsets = WindowInsets.navigationBars,
bottomBar = {
val bottomBarHazeStyle = HazeMaterials.thin()
Column(
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 0f,
endIntensity = 1f,
)
.hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
style = bottomBarHazeStyle
progressive = HazeProgressive.verticalGradient(
startIntensity = 0f,
endIntensity = 1f,
)
}
}
) {
if (peerDeleted && dmRecipientId != null) {
@@ -1045,6 +1049,7 @@ fun ChatScreen(
hazeState = hazeState,
supportsAttachments = panel.supportsAttachments,
isReadOnly = isReadOnly,
hazeBlurEnabled = hazeBlurEnabled,
onReadOnlyMessageClick = {
if (isReadOnly) {
showSuspendedSupportSheet = true
@@ -1383,11 +1388,13 @@ fun ChatScreen(
},
contentDescription = scrollToBottomCd,
hazeState = hazeState,
hazeBlurEnabled = hazeBlurEnabled,
)
}
ChatTopBar(
hazeState = hazeState,
hazeBlurEnabled = hazeBlurEnabled,
onBack = {
runNav { navController.navigateUp() }
},
@@ -1583,6 +1590,7 @@ private fun ChatScrollToBottomButton(
contentDescription: String,
hazeState: HazeState,
modifier: Modifier = Modifier,
hazeBlurEnabled: Boolean = true,
) {
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
Box(
@@ -1598,7 +1606,12 @@ private fun ChatScrollToBottomButton(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
.hazeEffect(state = hazeState, style = hazeStyle),
.hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
style = hazeStyle
}
},
)
Icon(
imageVector = Icons.Rounded.KeyboardArrowDown,
@@ -62,8 +62,9 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.conditional
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.blur.HazeBlurStyle
import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
@@ -325,6 +326,7 @@ fun ChatTopBar(
callContentDescription: String,
titleChrome: @Composable () -> Unit,
modifier: Modifier = Modifier,
hazeBlurEnabled: Boolean = true,
) {
val bottomCornerRadius = ChatFloatingHeaderBottomArcRadius
// [BottomInsetTopBarShape] draws the arc in the strip from y = (content height) .. (content + r);
@@ -370,6 +372,7 @@ fun ChatTopBar(
val layoutHeight = topBarPlaceable.height + arcExtentPx
val bgPlaceable = subcompose("background") {
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
Box(
modifier = Modifier
.fillMaxSize()
@@ -379,7 +382,12 @@ fun ChatTopBar(
}
)
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
.hazeEffect(state = hazeState, style = rememberChatSurfaceContainerHazeStyle()),
.hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
style = hazeStyle
}
},
)
}.first().measure(Constraints.fixed(layoutWidth, layoutHeight))
@@ -447,18 +455,21 @@ private class BottomInsetTopBarShape(private val cornerRadius: Dp) : Shape {
}
/**
* [HazeStyle] for chat chrome using only [androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainer]
* (24dp blur + luminance-scaled tint, slightly denser than old thin defaults), without [dev.chrisbanes.haze.materials.HazeMaterials].
* [HazeBlurStyle] for chat chrome using only [androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainer]
* (24dp blur + luminance-scaled tint, slightly denser than old thin defaults), without
* [dev.chrisbanes.haze.blur.materials.HazeMaterials].
*/
@Composable
fun rememberChatSurfaceContainerHazeStyle(): HazeStyle {
fun rememberChatSurfaceContainerHazeStyle(): HazeBlurStyle {
val surface = MaterialTheme.colorScheme.surfaceContainer
return remember(surface) {
HazeStyle(
HazeBlurStyle(
blurRadius = 24.dp,
backgroundColor = surface,
tint = HazeTint(surface.copy(alpha = if (surface.luminance() >= 0.5f) 0.74f else 0.79f)),
colorEffect = HazeColorEffect.tint(
surface.copy(alpha = if (surface.luminance() >= 0.5f) 0.74f else 0.79f),
),
)
}
}
@@ -534,7 +534,7 @@ fun MessageItem(
BoxWithConstraints(
modifier = Modifier.weight(1f, fill = false)
) {
val maxBubbleWidth = maxWidth * 0.7f
val maxBubbleWidth = minOf(maxWidth * 0.7f, 420.dp)
Column(
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
@@ -13,7 +13,7 @@ data class SelectedAttachment(
/**
* Platform-specific image picker. Returns a launch function.
* On Android: PickVisualMedia (images only, multiple).
* On iOS: Placeholder (no-op until Phase 4).
* On iOS: PHPickerViewController (images only, multiple).
*/
@Composable
expect fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit
@@ -21,7 +21,7 @@ expect fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit
/**
* Platform-specific file picker. Returns a launch function.
* On Android: OpenDocument/OpenMultipleDocuments (SAF).
* On iOS: Placeholder (no-op until Phase 4).
* On iOS: UIDocumentPickerViewController (multiple, asCopy).
*/
@Composable
expect fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit
@@ -83,12 +83,12 @@ import androidx.compose.ui.zIndex
import androidx.graphics.shapes.Morph
import androidx.graphics.shapes.RoundedPolygon
import com.pr0gramm3r101.utils.LastAnchoredBottomArrangement
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.HazeProgressive
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
@@ -333,7 +333,6 @@ fun MorphedExpressiveStepButton(
@OptIn(
ExperimentalAnimationApi::class,
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
)
@Composable
fun ExpressiveStepFlowScaffold(
@@ -549,6 +548,7 @@ fun ExpressiveStepFlowScaffold(
@Composable
fun HazeTopBar(hazeState: HazeState) {
val topBarHazeStyle = HazeMaterials.thin()
TopAppBar(
title = {},
navigationIcon = {
@@ -566,11 +566,14 @@ fun ExpressiveStepFlowScaffold(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
modifier = Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
modifier = Modifier.hazeEffect(state = hazeState) {
blurEffect {
style = topBarHazeStyle
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
}
},
)
}
@@ -12,30 +12,37 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.blur.HazeBlurStyle
import dev.chrisbanes.haze.blur.HazeProgressive
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun HazeBottomBar(
hazeState: HazeState,
modifier: Modifier = Modifier,
hazeStyle: HazeStyle? = null,
hazeStyle: HazeBlurStyle? = null,
content: @Composable () -> Unit,
) {
val resolvedStyle = hazeStyle ?: HazeMaterials.thin()
val effectModifier = if (hazeStyle != null) {
Modifier.hazeEffect(state = hazeState, style = hazeStyle)
Modifier.hazeEffect(state = hazeState) {
blurEffect {
style = resolvedStyle
}
}
} else {
Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 0f,
endIntensity = 1f,
)
Modifier.hazeEffect(state = hazeState) {
blurEffect {
style = resolvedStyle
progressive = HazeProgressive.verticalGradient(
startIntensity = 0f,
endIntensity = 1f,
)
}
}
}
Column(
@@ -57,14 +64,13 @@ fun HazeBottomBar(
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun HazeActionButton(
onClick: () -> Unit,
hazeState: HazeState,
modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier,
hazeStyle: HazeStyle? = null,
hazeStyle: HazeBlurStyle? = null,
enabled: Boolean = true,
loading: Boolean = false,
interactionSource: MutableInteractionSource? = null,
@@ -80,4 +86,4 @@ fun HazeActionButton(
content = content,
)
}
}
}
@@ -6,9 +6,11 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
@@ -44,14 +46,18 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.pr0gramm3r101.utils.NavigationItem
import com.pr0gramm3r101.utils.SimpleNavigationRail
import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
import com.pr0gramm3r101.utils.widthSizeClass
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.blur.HazeBlurStyle
import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
@@ -61,6 +67,7 @@ import ru.fromchat.contacts
import ru.fromchat.profile
import ru.fromchat.settings
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController
@@ -75,12 +82,17 @@ private const val PAGE_SETTINGS = 2
private const val PAGE_PROFILE = 3
private const val PAGE_COUNT = 4
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
/**
* @param listPaneOnly When true, renders only the chats list (no nav rail/bar) for use as the
* list side of an expanded listdetail conversation layout.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
snackbarHostState: SnackbarHostState? = null,
listPaneOnly: Boolean = false,
) {
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
val navController = LocalNavController.current
@@ -90,11 +102,14 @@ fun MainScreen(
)
val scope = rememberCoroutineScope()
val density = LocalDensity.current
val navBarHazeState = rememberHazeState(blurEnabled = true)
val navBarHazeState = rememberHazeState()
val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
val contextMenuHazeState = rememberHazeState(blurEnabled = true)
val contextMenuHazeState = rememberHazeState()
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
val useNavigationRail = !listPaneOnly &&
currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
val selectedPage = pagerState.currentPage
val isChatsPage = selectedPage == PAGE_CHATS
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
@@ -102,46 +117,83 @@ fun MainScreen(
val statusBarTopDp = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
var bottomChromeHeightDp by remember { mutableStateOf(0.dp) }
val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp) {
val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp, useNavigationRail, listPaneOnly) {
MainChromeInsets(
top = statusBarTopDp,
bottom = bottomChromeHeightDp,
bottom = if (useNavigationRail || listPaneOnly) 0.dp else bottomChromeHeightDp,
)
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.hazeSource(contextMenuHazeState),
) {
Scaffold(
snackbarHost = {
FromChatSnackbarHost(
hostState = effectiveSnackbarHostState,
modifier = Modifier.padding(bottom = mainChromeInsets.bottom),
)
},
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
val chatsLabel = stringResource(Res.string.chats)
val contactsLabel = stringResource(Res.string.contacts)
val settingsLabel = stringResource(Res.string.settings)
val profileLabel = stringResource(Res.string.profile)
BoxWithConstraints(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize()) {
if (useNavigationRail) {
SimpleNavigationRail(
selectedItem = selectedPage,
modifier = Modifier.fillMaxHeight(),
NavigationItem(
name = chatsLabel,
selectedIcon = Icons.AutoMirrored.Filled.Chat,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
},
),
NavigationItem(
name = contactsLabel,
selectedIcon = Icons.Filled.Contacts,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
},
),
NavigationItem(
name = settingsLabel,
selectedIcon = Icons.Filled.Settings,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
},
),
NavigationItem(
name = profileLabel,
selectedIcon = Icons.Filled.Person,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
},
),
)
}
Box(
modifier = Modifier
.fillMaxSize()
.imePadding(),
.weight(1f)
.fillMaxHeight()
.hazeSource(contextMenuHazeState),
) {
CompositionLocalProvider(LocalMainChromeInsets provides mainChromeInsets) {
Box(
modifier = Modifier
.fillMaxSize()
.hazeSource(navBarHazeState),
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
) { page ->
when (page) {
PAGE_CHATS -> ChatsTab(
isVisible = isChatsPage,
Scaffold(
snackbarHost = {
FromChatSnackbarHost(
hostState = effectiveSnackbarHostState,
modifier = Modifier.padding(bottom = mainChromeInsets.bottom),
)
},
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
modifier = Modifier
.fillMaxSize()
.imePadding(),
) {
CompositionLocalProvider(LocalMainChromeInsets provides mainChromeInsets) {
Box(
modifier = Modifier
.fillMaxSize()
.hazeSource(navBarHazeState),
) {
if (listPaneOnly) {
ChatsTab(
isVisible = true,
onOpenSearch = {
navController.navigate("search/conversations")
},
@@ -149,81 +201,118 @@ fun MainScreen(
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
PAGE_CONTACTS -> ContactsTab()
PAGE_SETTINGS -> SettingsTab()
PAGE_PROFILE -> {
ProfileScreen(
userId = ApiClient.user?.id,
onBack = {},
onChat = { _ -> },
modifier = Modifier
.fillMaxSize()
.mainPagerBottomInset(),
onOpenSettings = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
},
)
} else {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
) { page ->
when (page) {
PAGE_CHATS -> ChatsTab(
isVisible = isChatsPage,
onOpenSearch = {
navController.navigate("search/conversations")
},
chatContextMenuOverlay = chatContextMenuOverlay,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
PAGE_CONTACTS -> ContactsTab()
PAGE_SETTINGS -> SettingsTab()
PAGE_PROFILE -> {
ProfileScreen(
userId = ApiClient.user?.id,
onBack = {},
onChat = { _ -> },
modifier = Modifier
.fillMaxSize()
.mainPagerBottomInset(),
onOpenSettings = {
scope.launch {
pagerState.animateScrollToPage(PAGE_SETTINGS)
}
},
)
}
else -> Unit
}
}
else -> Unit
}
}
}
}
}
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.zIndex(1f)
.imePadding()
.onSizeChanged { size ->
val measured = with(density) { size.height.toDp() }
if (measured != bottomChromeHeightDp) {
bottomChromeHeightDp = measured
if (!useNavigationRail && !listPaneOnly) {
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.zIndex(1f)
.imePadding()
.onSizeChanged { size ->
val measured = with(density) { size.height.toDp() }
if (measured != bottomChromeHeightDp) {
bottomChromeHeightDp = measured
}
}
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(state = navBarHazeState) {
blurEffect {
style = navBarHazeStyle
}
},
) {
NavigationBar(
modifier = Modifier.fillMaxWidth(),
containerColor = Color.Transparent,
tonalElevation = 0.dp,
windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
) {
NavigationBarItem(
selected = selectedPage == PAGE_CHATS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
},
label = { Text(chatsLabel) },
icon = {
Icon(
Icons.AutoMirrored.Filled.Chat,
contentDescription = null,
)
},
)
NavigationBarItem(
selected = selectedPage == PAGE_CONTACTS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
},
label = { Text(contactsLabel) },
icon = {
Icon(Icons.Filled.Contacts, contentDescription = null)
},
)
NavigationBarItem(
selected = selectedPage == PAGE_SETTINGS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
},
label = { Text(settingsLabel) },
icon = {
Icon(Icons.Filled.Settings, contentDescription = null)
},
)
NavigationBarItem(
selected = selectedPage == PAGE_PROFILE,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
},
label = { Text(profileLabel) },
icon = {
Icon(Icons.Filled.Person, contentDescription = null)
},
)
}
}
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(state = navBarHazeState, style = navBarHazeStyle),
) {
NavigationBar(
modifier = Modifier.fillMaxWidth(),
containerColor = Color.Transparent,
tonalElevation = 0.dp,
windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
) {
NavigationBarItem(
selected = selectedPage == PAGE_CHATS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
},
label = { Text(stringResource(Res.string.chats)) },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) },
)
NavigationBarItem(
selected = selectedPage == PAGE_CONTACTS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
},
label = { Text(stringResource(Res.string.contacts)) },
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) },
)
NavigationBarItem(
selected = selectedPage == PAGE_SETTINGS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
},
label = { Text(stringResource(Res.string.settings)) },
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
)
NavigationBarItem(
selected = selectedPage == PAGE_PROFILE,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
},
label = { Text(stringResource(Res.string.profile)) },
icon = { Icon(Icons.Filled.Person, contentDescription = null) },
)
}
}
}
@@ -249,7 +338,6 @@ fun MainScreen(
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun ChatContextMenuBlurLayer(
hazeState: HazeState,
@@ -260,15 +348,16 @@ private fun ChatContextMenuBlurLayer(
if (blurRadius <= 0.dp) return
Box(
modifier = modifier.hazeEffect(
state = hazeState,
style = HazeStyle(
blurRadius = blurRadius,
tints = emptyList(),
backgroundColor = Color.Transparent,
noiseFactor = 0f,
fallbackTint = HazeTint(Color.Transparent),
),
),
modifier = modifier.hazeEffect(state = hazeState) {
blurEffect {
style = HazeBlurStyle(
blurRadius = blurRadius,
colorEffects = emptyList(),
backgroundColor = Color.Transparent,
noiseFactor = 0f,
fallbackColorEffect = HazeColorEffect.tint(Color.Transparent),
)
}
},
)
}
@@ -59,10 +59,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.ui.graphics.Color
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -340,20 +340,23 @@ private fun chatsTopAppBarColors(blurReveal: Float) = TopAppBarDefaults.topAppBa
scrolledContainerColor = Color.Transparent,
)
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun ChatsTopBarHazeBackdrop(
hazeState: HazeState,
blurReveal: Float,
blurEnabled: Boolean,
modifier: Modifier = Modifier,
) {
val topBarHazeStyle = HazeMaterials.thin()
Box(
modifier = modifier
.graphicsLayer { alpha = blurReveal.coerceIn(0f, 1f) }
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin(),
),
.hazeEffect(state = hazeState) {
blurEffect {
this.blurEnabled = blurEnabled
style = topBarHazeStyle
}
},
)
}
@@ -518,7 +521,7 @@ private fun ChatsSelectionTopBar(
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatsTab(
isVisible: Boolean = true,
@@ -573,7 +576,7 @@ fun ChatsTab(
val publicChatProfile = publicChatProfileFromFlow ?: publicChatProfileFromDisk
val searchBarHint = stringResource(Res.string.search_title)
val tabListState = rememberLazyListState()
val topChromeHazeState = rememberHazeState(blurEnabled = isVisible)
val topChromeHazeState = rememberHazeState()
val statusMap by UserStatusStore.status.collectAsState()
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
val statusSubscriptionScope = rememberCoroutineScope()
@@ -1032,6 +1035,7 @@ fun ChatsTab(
ChatsTopBarHazeBackdrop(
hazeState = topChromeHazeState,
blurReveal = topBarBlurReveal,
blurEnabled = isVisible,
modifier = Modifier.matchParentSize(),
)
Box(modifier = Modifier.fillMaxWidth()) {
@@ -67,11 +67,11 @@ import coil3.compose.AsyncImage
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.utils.currentDeviceInfo
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.blur.HazeProgressive
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import io.ktor.client.network.sockets.ConnectTimeoutException
import io.ktor.client.network.sockets.SocketTimeoutException
@@ -363,13 +363,13 @@ private fun DeviceSessionDetailBottomSheet(
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class,
@OptIn(ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class
)
@Composable
fun DevicesScreen(onBack: () -> Unit) {
val scope = rememberCoroutineScope()
val hazeState = rememberHazeState(blurEnabled = true)
val hazeState = rememberHazeState()
val snackbarHostState = remember { SnackbarHostState() }
val initialCache = remember { Settings.readDeviceSessionsCache() }
@@ -430,6 +430,7 @@ fun DevicesScreen(onBack: () -> Unit) {
contentWindowInsets = WindowInsets.navigationBars,
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = {
val topBarHazeStyle = HazeMaterials.thin()
TopAppBar(
title = {},
navigationIcon = {
@@ -441,14 +442,14 @@ fun DevicesScreen(onBack: () -> Unit) {
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
),
modifier = Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
modifier = Modifier.hazeEffect(state = hazeState) {
blurEffect {
style = topBarHazeStyle
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
}
}
)
},
@@ -707,6 +707,7 @@ private fun formatLogFileSize(sizeBytes: Long): String {
if (sizeBytes < 1024 * 1024) {
return stringResource(Res.string.logs_file_size_kb, kb)
}
val megabytes = "%.1f".format(sizeBytes / (1024f * 1024f))
val mb = sizeBytes / (1024.0 * 1024.0)
val megabytes = ((kotlin.math.round(mb * 10.0) / 10.0)).toString()
return stringResource(Res.string.logs_file_size_mb, megabytes)
}
@@ -44,6 +44,7 @@ import ru.fromchat.settings_notifications_permission_required
import ru.fromchat.settings_notifications_title
import ru.fromchat.settings_push_notifications
import ru.fromchat.settings_push_notifications_d
import ru.fromchat.settings_push_notifications_unavailable
import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.Text
@@ -84,48 +85,58 @@ fun NotificationsScreen(onBack: () -> Unit) {
Category(
modifier = Modifier.padding(top = 16.dp)
) {
SwitchListItem(
headline = stringResource(Res.string.settings_push_notifications),
supportingText = stringResource(Res.string.settings_push_notifications_d),
leadingContent = {
Icon(Icons.Filled.Notifications, null)
},
checked = notificationsEnabled,
onCheckedChange = { enabled ->
coroutineScope.launch {
if (enabled) {
if (!areAppNotificationsEnabled()) {
if (!openAppNotificationSettings()) {
snackbarHostState.showSnackbar(message = unexpectedErrorText)
} else {
snackbarHostState.showSnackbar(message = notificationsPermissionText)
if (arePushNotificationsSupported()) {
SwitchListItem(
headline = stringResource(Res.string.settings_push_notifications),
supportingText = stringResource(Res.string.settings_push_notifications_d),
leadingContent = {
Icon(Icons.Filled.Notifications, null)
},
checked = notificationsEnabled,
onCheckedChange = { enabled ->
coroutineScope.launch {
if (enabled) {
if (!areAppNotificationsEnabled()) {
if (!openAppNotificationSettings()) {
snackbarHostState.showSnackbar(message = unexpectedErrorText)
} else {
snackbarHostState.showSnackbar(message = notificationsPermissionText)
}
return@launch
}
return@launch
}
val registered = ensureFcmTokenRegistered()
if (registered) {
notificationsEnabled = true
val registered = ensureFcmTokenRegistered()
if (registered) {
notificationsEnabled = true
} else {
snackbarHostState.showSnackbar(message = unexpectedErrorText)
}
} else {
snackbarHostState.showSnackbar(message = unexpectedErrorText)
unregisterFcmTokenFromServer()
notificationsEnabled = false
}
} else {
unregisterFcmTokenFromServer()
notificationsEnabled = false
}
}
},
divider = true
)
},
divider = true
)
ListItem(
headline = stringResource(Res.string.settings_notification_settings),
supportingText = stringResource(Res.string.settings_notification_settings_d),
leadingContent = {
Icon(Icons.Filled.Settings, null)
},
onClick = { openAppNotificationSettings() }
)
ListItem(
headline = stringResource(Res.string.settings_notification_settings),
supportingText = stringResource(Res.string.settings_notification_settings_d),
leadingContent = {
Icon(Icons.Filled.Settings, null)
},
onClick = { openAppNotificationSettings() }
)
} else {
ListItem(
headline = stringResource(Res.string.settings_push_notifications),
supportingText = stringResource(Res.string.settings_push_notifications_unavailable),
leadingContent = {
Icon(Icons.Filled.Notifications, null)
},
)
}
}
}
}
@@ -140,4 +151,7 @@ expect fun openAppNotificationSettings(): Boolean
/**
* Returns true when notifications are currently enabled for this app, including runtime permission.
*/
expect fun areAppNotificationsEnabled(): Boolean
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 com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.toDp
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.blur.HazeProgressive
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -129,7 +129,6 @@ private object ServerConfigLazyListIndices {
ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class,
ExperimentalAnimationApi::class,
ExperimentalHazeMaterialsApi::class,
)
@Composable
fun ServerConfigScreen() {
@@ -331,6 +330,7 @@ fun ServerConfigScreen() {
}
},
topBar = {
val topBarHazeStyle = HazeMaterials.thin()
TopAppBar(
title = {},
navigationIcon = {
@@ -345,11 +345,14 @@ fun ServerConfigScreen() {
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
),
modifier = Modifier.hazeEffect(state = actionHazeState, style = HazeMaterials.thin()) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
modifier = Modifier.hazeEffect(state = actionHazeState) {
blurEffect {
style = topBarHazeStyle
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
}
}
)
}
@@ -47,11 +47,11 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.blur.HazeProgressive
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
@@ -110,7 +110,6 @@ private object EditProfileLazyListIndices {
ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class,
ExperimentalFoundationApi::class,
ExperimentalHazeMaterialsApi::class,
)
@Composable
fun EditProfileScreen(
@@ -340,6 +339,7 @@ fun EditProfileScreen(
}
},
topBar = {
val topBarHazeStyle = HazeMaterials.thin()
TopAppBar(
title = { Text(stringResource(Res.string.profile_edit_title)) },
navigationIcon = {
@@ -367,11 +367,14 @@ fun EditProfileScreen(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
modifier = Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
modifier = Modifier.hazeEffect(state = hazeState) {
blurEffect {
style = topBarHazeStyle
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
}
},
)
},
@@ -37,11 +37,13 @@ import androidx.compose.ui.unit.Dp
import androidx.navigation.NavController
import com.pr0gramm3r101.components.ListItemPosition
import com.pr0gramm3r101.utils.SupportClipboardManager
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.HazeProgressive
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.CoroutineScope
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
@@ -112,8 +114,6 @@ import com.pr0gramm3r101.components.ContextMenuPressable
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.components.listItemPositionInGroup
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
@@ -2028,7 +2028,6 @@ private fun ExpressiveProfileActionButton(
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun ProfileFloatingBackBar(
visible: Boolean,
@@ -2039,16 +2038,20 @@ private fun ProfileFloatingBackBar(
) {
if (!visible) return
val backBarHazeStyle = HazeMaterials.thin()
Box(modifier = modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(blurHeight)
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
.hazeEffect(state = hazeState) {
blurEffect {
style = backBarHazeStyle
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
}
},
)
IconButton(
@@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.send.installOutboxForegroundDrain
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
object IosApplicationBootstrap {
@@ -16,6 +17,7 @@ object IosApplicationBootstrap {
fun launchOnApplicationStart() {
if (started) return
started = true
installOutboxForegroundDrain()
scope.launch {
runCatching { ApiClient.loadPersistedData() }
runCatching { AttachmentTransferBootstrap.runColdStart() }
@@ -0,0 +1,3 @@
package ru.fromchat
actual fun keepWebSocketAliveInBackground(): Boolean = false
@@ -1,5 +1,5 @@
package ru.fromchat.api
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.create
import platform.Foundation.writeToFile
import ru.fromchat.api.db.MessageDatabaseProvider
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
private const val GENERATION_FILE = ".generation"
@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
package ru.fromchat.api.local.cache
package ru.fromchat.api.local.download
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
@@ -23,8 +23,6 @@ import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext
import platform.UIKit.UIImage
import platform.UIKit.UIImageOrientation
import platform.UIKit.UIImagePNGRepresentation
import ru.fromchat.api.local.download.decodeImageBytes
actual object PlatformDecodedBitmapCache {
private val cache = mutableMapOf<String, ImageBitmap>()
@@ -1,5 +1,36 @@
@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
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.launch
import platform.Foundation.NSNotificationCenter
import platform.UIKit.UIApplicationDidBecomeActiveNotification
import ru.fromchat.api.local.cache.CacheContext
/** iOS: drain outbox on enqueue and when the app returns to foreground (parity with WorkManager). */
private var foregroundDrainInstalled = false
/** Install once: drain outbox when the app becomes active (parity with WorkManager). */
fun installOutboxForegroundDrain() {
if (foregroundDrainInstalled) return
foregroundDrainInstalled = true
NSNotificationCenter.defaultCenter.addObserverForName(
name = UIApplicationDidBecomeActiveNotification,
`object` = null,
queue = null,
) { _ ->
val instanceId = CacheContext.activeInstanceId.value.trim()
if (instanceId.isNotEmpty()) {
scheduleOutboxProcessing(instanceId)
}
}
}
/** iOS: drain outbox on enqueue and when the app returns to foreground. */
actual fun scheduleOutboxProcessing(instanceId: String) {
if (instanceId.trim().isEmpty()) return
MainScope().launch {
@@ -1,8 +1,12 @@
package ru.fromchat.api.local.workers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.SharedFlow
import ru.fromchat.api.local.send.scheduleOutboxProcessing
import kotlinx.coroutines.launch
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.local.send.scheduleOutboxProcessing
actual object AttachmentUploadQueue {
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = AttachmentUploadNotifier.progressFlow
@@ -12,6 +16,14 @@ actual object AttachmentUploadQueue {
}
actual fun cancel(jobId: String) {
// Outbox + artifact cleanup handled when user deletes pending message from UI.
val instanceId = CacheContext.activeInstanceId.value.trim()
if (instanceId.isEmpty()) return
MainScope().launch {
val row = MessageDatabaseProvider.database.messageDatabaseQueries
.selectOutboxItem(instanceId, jobId.trim())
.executeAsOneOrNull()
val conversationId = row?.conversationId ?: return@launch
OutgoingMessageCoordinator.cancelOutboundMessage(jobId, conversationId)
}
}
}
@@ -2,21 +2,28 @@ package ru.fromchat.logging
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.UByteVar
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.alloc
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.convert
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.sizeOf
import kotlinx.cinterop.toCValues
import kotlinx.cinterop.usePinned
import kotlinx.cinterop.value
import platform.posix.memcpy
import platform.zlib.Z_DEFAULT_COMPRESSION
import platform.zlib.compress2
import platform.zlib.uLongVar
@OptIn(ExperimentalForeignApi::class)
internal actual fun gzipCompress(input: ByteArray): ByteArray {
if (input.isEmpty()) {
return byteArrayOf(0x1f, 0x8b.toByte(), 0x08, 0x00, 0, 0, 0, 0, 0, 0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
return byteArrayOf(
0x1f, 0x8b.toByte(), 0x08, 0x00, 0, 0, 0, 0, 0, 0x03,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
)
}
val header = byteArrayOf(
@@ -50,34 +57,42 @@ internal actual fun gzipCompress(input: ByteArray): ByteArray {
}
@OptIn(ExperimentalForeignApi::class)
private fun deflateRaw(input: ByteArray): ByteArray = memScoped {
if (input.isEmpty()) return@memScoped ByteArray(0)
private fun deflateRaw(input: ByteArray): ByteArray {
if (input.isEmpty()) return ByteArray(0)
var capacity = (input.size + (input.size / 10) + 12).coerceAtLeast(64)
while (true) {
val output = allocArray<UByteVar>(capacity)
val source = input.toUByteArray().toCValues()
val sourceLength = input.size.convert<platform.zlib.uLong>()
val destLength = alloc<platform.zlib.uLongVar>()
destLength.value = capacity.convert()
return memScoped {
var capacity = (input.size + (input.size / 10) + 12).coerceAtLeast(64)
while (true) {
val output = allocArray<UByteVar>(capacity)
val source = input.toUByteArray().toCValues()
val sourceLength = input.size.convert<platform.zlib.uLong>()
val destLength = alloc<uLongVar>()
destLength.value = capacity.convert()
val status = compress2(
output,
destLength.ptr,
source.ptr.reinterpret(),
sourceLength,
Z_DEFAULT_COMPRESSION,
)
val status = compress2(
output,
destLength.ptr,
source.ptr.reinterpret(),
sourceLength,
Z_DEFAULT_COMPRESSION,
)
if (status == 0) {
val size = destLength.value.toInt()
return@memScoped ByteArray(size) { index -> output[index].toByte() }
}
capacity *= 2
if (capacity > input.size * 20) {
return@memScoped input
if (status == 0) {
val size = destLength.value.toInt()
val result = ByteArray(size)
result.usePinned { pinned ->
memcpy(pinned.addressOf(0), output, size.convert())
}
return@memScoped result
}
capacity *= 2
if (capacity > input.size * 20) {
return@memScoped input
}
}
@Suppress("UNREACHABLE_CODE")
ByteArray(0)
}
}
@@ -6,28 +6,30 @@ import kotlinx.cinterop.addressOf
import kotlinx.cinterop.alloc
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.convert
import kotlinx.cinterop.get
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.toCValues
import kotlinx.cinterop.usePinned
import kotlinx.cinterop.value
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
import platform.Foundation.NSString
import platform.Foundation.NSUTF8StringEncoding
import platform.Foundation.create
import platform.Foundation.dataWithContentsOfFile
import platform.Foundation.writeToFile
import platform.posix.memcpy
import platform.zlib.uLongVar
import platform.zlib.uncompress
@OptIn(ExperimentalForeignApi::class)
internal actual object LogFileOps {
actual fun readText(path: String): String {
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return ""
return NSString.stringWithContentsOfFile(path, encoding = NSUTF8StringEncoding, error = null) as? String
?: ""
val raw = NSData.dataWithContentsOfFile(path) ?: return ""
return raw.toByteArray().decodeToString()
}
actual fun readBytes(path: String): ByteArray {
@@ -36,53 +38,60 @@ internal actual object LogFileOps {
return raw.toByteArray()
}
actual suspend fun gzipFile(sourcePath: String, destinationPath: String) = withContext(Dispatchers.Default) {
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@withContext
val raw = NSData.dataWithContentsOfFile(sourcePath) ?: return@withContext
val bytes = raw.toByteArray()
val gzipped = gzipCompress(bytes)
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
NSData.create(bytes = gzipped, length = gzipped.size.toULong())
.writeToFile(destinationPath, true)
NSFileManager.defaultManager.removeItemAtPath(sourcePath, null)
actual suspend fun gzipFile(sourcePath: String, destinationPath: String) {
withContext(Dispatchers.Default) {
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@withContext
val raw = NSData.dataWithContentsOfFile(sourcePath) ?: return@withContext
val gzipped = gzipCompress(raw.toByteArray())
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
writeBytes(destinationPath, gzipped)
NSFileManager.defaultManager.removeItemAtPath(sourcePath, null)
}
}
actual suspend fun readGzipText(path: String, onProgress: (Float) -> Unit): String =
withContext(Dispatchers.Default) {
val bytes = gunzipToByteArray(path, onProgress)
bytes.decodeToString()
gunzipToByteArray(path, onProgress).decodeToString()
}
actual suspend fun gunzipToFile(
sourcePath: String,
destinationPath: String,
onProgress: (Float) -> Unit,
) = withContext(Dispatchers.Default) {
val bytes = gunzipToByteArray(sourcePath, onProgress)
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
NSData.create(bytes = bytes, length = bytes.size.toULong())
.writeToFile(destinationPath, true)
) {
withContext(Dispatchers.Default) {
val bytes = gunzipToByteArray(sourcePath, onProgress)
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
writeBytes(destinationPath, bytes)
}
}
actual suspend fun zipFiles(
entries: List<Pair<String, String>>,
destinationPath: String,
) = withContext(Dispatchers.Default) {
if (entries.isEmpty()) return@withContext
val zipEntries = entries.mapNotNull { (entryName, sourcePath) ->
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@mapNotNull null
ZipFileEntry(entryName, readBytes(sourcePath))
) {
withContext(Dispatchers.Default) {
if (entries.isEmpty()) return@withContext
val zipEntries = entries.mapNotNull { (entryName, sourcePath) ->
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@mapNotNull null
ZipFileEntry(entryName, readBytes(sourcePath))
}
val bytes = buildStoreZipArchive(zipEntries)
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
writeBytes(destinationPath, bytes)
}
}
private fun writeBytes(path: String, bytes: ByteArray) {
bytes.usePinned { pinned ->
NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
?.writeToFile(path, true)
}
val bytes = buildStoreZipArchive(zipEntries)
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
NSData.create(bytes = bytes, length = bytes.size.toULong())
.writeToFile(destinationPath, true)
}
@OptIn(ExperimentalForeignApi::class)
private fun gunzipToByteArray(path: String, onProgress: (Float) -> Unit): ByteArray {
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) {
onProgress(1f)
@@ -103,17 +112,16 @@ internal actual object LogFileOps {
return inflated
}
@OptIn(ExperimentalForeignApi::class)
private fun inflateGzipPayload(deflated: ByteArray): ByteArray = memScoped {
if (deflated.isEmpty()) return@memScoped ByteArray(0)
var capacity = (deflated.size * 4).coerceAtLeast(256)
while (capacity <= deflated.size * 32) {
val output = allocArray<UByteVar>(capacity)
val destLength = alloc<platform.zlib.uLongVar>()
destLength.value = capacity.convert<platform.zlib.uLong>()
val destLength = alloc<uLongVar>()
destLength.value = capacity.convert()
val source = deflated.toUByteArray().toCValues()
val status = platform.zlib.uncompress(
val status = uncompress(
output,
destLength.ptr,
source.ptr.reinterpret(),
@@ -121,14 +129,17 @@ internal actual object LogFileOps {
)
if (status == 0) {
val size = destLength.value.toInt()
return@memScoped ByteArray(size) { index -> output[index].toByte() }
val result = ByteArray(size)
result.usePinned { pinned ->
memcpy(pinned.addressOf(0), output, size.convert())
}
return@memScoped result
}
capacity *= 2
}
ByteArray(0)
}
@OptIn(ExperimentalForeignApi::class)
private fun NSData.toByteArray(): ByteArray {
val length = this.length.toInt()
if (length == 0) return ByteArray(0)
@@ -1,10 +1,67 @@
@file:OptIn(ExperimentalForeignApi::class)
package ru.fromchat.ui.auth.captcha
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.viewinterop.UIKitView
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCSignatureOverride
import platform.CoreGraphics.CGRectMake
import platform.Foundation.NSError
import platform.Foundation.NSURL
import platform.Foundation.NSURLRequest
import platform.UIKit.UIColor
import platform.WebKit.WKNavigation
import platform.WebKit.WKNavigationAction
import platform.WebKit.WKNavigationActionPolicy
import platform.WebKit.WKNavigationDelegateProtocol
import platform.WebKit.WKScriptMessage
import platform.WebKit.WKScriptMessageHandlerProtocol
import platform.WebKit.WKUserContentController
import platform.WebKit.WKUserScript
import platform.WebKit.WKUserScriptInjectionTime
import platform.WebKit.WKWebView
import platform.WebKit.WKWebViewConfiguration
import platform.darwin.NSObject
import ru.fromchat.Logger
private const val SMARTCAPTCHA_WEBVIEW_BASE = "https://smartcaptcha.cloud.yandex.ru/webview"
private const val NATIVE_CLIENT_HANDLER = "NativeClient"
/**
* Injected at document start so [window.NativeClient] matches Android's JavascriptInterface
* before SmartCaptcha scripts run (including iframes).
*/
private const val NATIVE_CLIENT_BRIDGE_JS = """
(function() {
if (window.NativeClient && window.NativeClient.__fromchat) return;
function post(payload) {
try {
window.webkit.messageHandlers.NativeClient.postMessage(payload);
} catch (e) {}
}
window.NativeClient = {
__fromchat: true,
onGetToken: function(token) {
post({ method: 'onGetToken', token: String(token == null ? '' : token) });
},
onChallengeVisible: function() {
post({ method: 'onChallengeVisible' });
},
onChallengeHidden: function() {
post({ method: 'onChallengeHidden' });
}
};
})();
"""
@Composable
actual fun SmartCaptchaWebView(
sitekey: String,
@@ -16,12 +73,186 @@ actual fun SmartCaptchaWebView(
onChallengeHidden: () -> Unit,
onError: (String) -> Unit,
) {
LaunchedEffect(sitekey) {
Logger.w(
SmartCaptchaLog.TAG,
"iOS stub: captcha unavailable sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
"languageTag=$languageTag",
)
onError("Captcha is not available on this platform yet.")
val onTokenState = rememberUpdatedState(onToken)
val onReadyState = rememberUpdatedState(onReady)
val onChallengeVisibleState = rememberUpdatedState(onChallengeVisible)
val onChallengeHiddenState = rememberUpdatedState(onChallengeHidden)
val onErrorState = rememberUpdatedState(onError)
val background = MaterialTheme.colorScheme.surfaceContainer
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
val captchaUrl = remember(sitekey, lang) {
"$SMARTCAPTCHA_WEBVIEW_BASE?sitekey=${sitekey.trim()}&hl=$lang"
}
val instanceId = remember { (100000..999999).random().toString(16) }
val heldWebView = remember { arrayOfNulls<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
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.viewinterop.UIKitView
import kotlinx.coroutines.delay
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCSignatureOverride
import platform.CoreGraphics.CGRectMake
import platform.Foundation.NSHTTPCookie
import platform.Foundation.NSHTTPCookieStorage
import platform.Foundation.NSURL
import platform.Foundation.NSURLRequest
import platform.UIKit.UIApplication
import platform.UIKit.UIColor
import platform.WebKit.WKNavigation
import platform.WebKit.WKNavigationAction
import platform.WebKit.WKNavigationActionPolicy
import platform.WebKit.WKNavigationDelegateProtocol
import platform.WebKit.WKUserScript
import platform.WebKit.WKUserScriptInjectionTime
import platform.WebKit.WKWebView
import platform.WebKit.WKWebViewConfiguration
import platform.darwin.NSObject
import ru.fromchat.Logger
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
import ru.fromchat.auth.yandex.extractOAuthCode
private const val LOG_TAG = "YandexOAuthWV"
private const val LOADING_FADE_MS = 250
private const val LOADING_HIDE_DELAY_MS = 500L
private fun shortUrl(url: String?): String {
if (url.isNullOrBlank()) return "null"
return if (url.length <= 120) url else url.take(117) + "..."
}
private const val FORCE_COLOR_SCHEME_JS = """
(function(scheme) {
try {
var meta = document.querySelector('meta[name="color-scheme"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'color-scheme';
(document.head || document.documentElement).appendChild(meta);
}
meta.content = scheme;
document.documentElement.style.colorScheme = scheme;
document.documentElement.setAttribute('data-theme', scheme);
document.documentElement.classList.toggle('theme_dark', scheme === 'dark');
document.documentElement.classList.toggle('Theme_color_dark', scheme === 'dark');
document.documentElement.classList.toggle('theme_light', scheme === 'light');
try { localStorage.setItem('color-scheme', scheme); } catch (e) {}
try { localStorage.setItem('theme', scheme); } catch (e) {}
} catch (e) {}
})
"""
private const val DISABLE_USER_SELECT_JS = """
(function() {
try {
var id = 'fromchat-no-select';
if (document.getElementById(id)) return;
var s = document.createElement('style');
s.id = id;
s.textContent = [
'*,*::before,*::after{',
'-webkit-user-select:none!important;',
'user-select:none!important;',
'-webkit-touch-callout:none!important;',
'}',
'input,textarea,select,[contenteditable],[contenteditable="true"],',
'input *,textarea *,[contenteditable] *,[contenteditable="true"] *{',
'-webkit-user-select:text!important;',
'user-select:text!important;',
'-webkit-touch-callout:default!important;',
'}'
].join('');
(document.head || document.documentElement).appendChild(s);
} catch (e) {}
})();
"""
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
actual fun YandexOAuthWebView(
authorizeUrl: String,
@@ -17,7 +116,287 @@ actual fun YandexOAuthWebView(
onError: (String) -> Unit,
onCancel: () -> Unit,
) {
LaunchedEffect(authorizeUrl) {
onError("Yandex sign-in is not available on this platform yet.")
var canGoBack by remember { mutableStateOf(false) }
var pageLoading by remember { mutableStateOf(true) }
var showLoadingOverlay by remember { mutableStateOf(true) }
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
val scheme = if (darkTheme) "dark" else "light"
val onHistoryBackAvailabilityChangedState = rememberUpdatedState(onHistoryBackAvailabilityChanged)
val onPageBackgroundColorState = rememberUpdatedState(onPageBackgroundColor)
val onCodeState = rememberUpdatedState(onCode)
val onErrorState = rememberUpdatedState(onError)
val instanceId = remember { (100000..999999).random().toString(16) }
val heldWebView = remember { arrayOfNulls<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
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.UIKitView
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCSignatureOverride
import platform.Foundation.NSError
import platform.WebKit.WKNavigation
import platform.WebKit.WKNavigationDelegateProtocol
import platform.WebKit.WKScriptMessage
import platform.WebKit.WKScriptMessageHandlerProtocol
import platform.WebKit.WKUserContentController
import platform.WebKit.WKWebView
import platform.WebKit.WKWebViewConfiguration
import platform.WebKit.WKAudiovisualMediaTypeNone
import platform.CoreGraphics.CGRectMake
import platform.darwin.NSObject
import ru.fromchat.Logger
import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.calls.LiveKitConnectSession
private const val TAG = "CallMediaLayer"
private const val HANDLER_NAME = "FromChatCall"
@Composable
actual fun CallMediaLayer(
connect: LiveKitConnectSession?,
@@ -13,5 +45,182 @@ actual fun CallMediaLayer(
showInCallControls: Boolean,
modifier: Modifier,
) {
Box(modifier = modifier.fillMaxSize())
when {
connect == null && showDialingPlaceholder -> {
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
connect != null -> {
key(connect.roomName) {
LiveKitWkWebCall(
session = connect,
showInCallControls = showInCallControls,
modifier = modifier,
)
}
}
else -> Box(modifier = modifier.fillMaxSize())
}
}
@Composable
private fun LiveKitWkWebCall(
session: LiveKitConnectSession,
showInCallControls: Boolean,
modifier: Modifier,
) {
var micOn by remember(session.roomName) { mutableStateOf(true) }
var camOn by remember(session.roomName) { mutableStateOf(true) }
val sessionState = rememberUpdatedState(session)
val heldWebView = remember { arrayOfNulls<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
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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.useContents
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.Foundation.NSCachesDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSLock
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSURL
import platform.Foundation.NSUUID
import platform.Foundation.NSUserDomainMask
import platform.PhotosUI.PHPickerConfiguration
import platform.PhotosUI.PHPickerFilter
import platform.PhotosUI.PHPickerResult
import platform.PhotosUI.PHPickerViewController
import platform.PhotosUI.PHPickerViewControllerDelegateProtocol
import platform.UIKit.UIDocumentPickerDelegateProtocol
import platform.UIKit.UIDocumentPickerViewController
import platform.UIKit.UIImage
import platform.UniformTypeIdentifiers.UTTypeImage
import platform.UniformTypeIdentifiers.UTTypeItem
import platform.darwin.NSObject
import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue
import ru.fromchat.utils.iosTopViewController
actual fun getFilenameFromUri(uri: String): String {
return uri.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
val path = when {
uri.startsWith("file://") -> NSURL.URLWithString(uri)?.path ?: uri
else -> uri
}
return path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
}
@Composable
actual fun rememberImagePicker(onResult: (List<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
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.remember
@@ -14,8 +14,8 @@ actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
return remember {
actual fun rememberHapticFeedbackInternal(): (Int) -> Unit =
remember {
{ ordinal ->
val style: UIImpactFeedbackStyle = when (ordinal) {
HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
@@ -23,8 +23,6 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
HapticFeedbackEvent.SelectionModeEntered.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight
}
val generator = UIImpactFeedbackGenerator(style)
generator.impactOccurred()
UIImpactFeedbackGenerator(style).impactOccurred()
}
}
}
@@ -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)
}
actual fun areAppNotificationsEnabled(): Boolean {
return true
}
actual fun areAppNotificationsEnabled(): Boolean = false
actual fun arePushNotificationsSupported(): Boolean = false
@@ -1,15 +1,44 @@
@file:OptIn(ExperimentalForeignApi::class)
package ru.fromchat.utils
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import platform.Network.nw_path_get_status
import platform.Network.nw_path_monitor_create
import platform.Network.nw_path_monitor_set_queue
import platform.Network.nw_path_monitor_set_update_handler
import platform.Network.nw_path_monitor_start
import platform.Network.nw_path_monitor_t
import platform.Network.nw_path_status_satisfied
import platform.darwin.dispatch_get_main_queue
import ru.fromchat.api.local.WebSocketManager
// TODO iOS: wired later to NWPathMonitor; default [true] avoids false "offline" when APIs are absent.
actual object NetworkConnectivity {
private val _isOnline = MutableStateFlow(true)
actual val isOnline: StateFlow<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() {
// 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