mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement desktop support
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
Generated
+8
@@ -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>
|
||||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="CompilerConfiguration">
|
<component name="CompilerConfiguration">
|
||||||
<bytecodeTargetLevel target="21" />
|
<bytecodeTargetLevel target="17" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
Generated
+1
@@ -12,6 +12,7 @@
|
|||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
<option value="$PROJECT_DIR$/app/android" />
|
<option value="$PROJECT_DIR$/app/android" />
|
||||||
|
<option value="$PROJECT_DIR$/app/desktop" />
|
||||||
<option value="$PROJECT_DIR$/app/shared" />
|
<option value="$PROJECT_DIR$/app/shared" />
|
||||||
<option value="$PROJECT_DIR$/utils" />
|
<option value="$PROJECT_DIR$/utils" />
|
||||||
<option value="$PROJECT_DIR$/utils/android" />
|
<option value="$PROJECT_DIR$/utils/android" />
|
||||||
|
|||||||
Generated
+7
@@ -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>
|
||||||
Generated
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectType">
|
<component name="ProjectType">
|
||||||
|
|||||||
+11
-10
@@ -2,14 +2,14 @@ Read in other languages: [Русский](./README.md)
|
|||||||
|
|
||||||
# FromChat
|
# FromChat
|
||||||
|
|
||||||
FromChat is a 100% free and open-source messenger. This repository is the cross-platform client (Android + iOS; iOS is not ready yet).
|
FromChat is a 100% free and open-source messenger. This repository is the cross-platform client (Android, Desktop via Compose Multiplatform, and iOS).
|
||||||
|
|
||||||
[📥 Download](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram Channel](https://t.me/fromchat_ch) • [🖥️ Server](https://github.com/fromchat-messenger/backend)
|
[📥 Download](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram Channel](https://t.me/fromchat_ch) • [🖥️ Server](https://github.com/fromchat-messenger/backend)
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Voice and video calls** — LiveKit
|
- **Voice and video calls** — LiveKit
|
||||||
- **Screen sharing** during calls
|
- **Screen sharing** during calls (Android)
|
||||||
- **Public chat** — server-wide community
|
- **Public chat** — server-wide community
|
||||||
- **Direct messages** — legal encryption scheme; server-side E2EE planned
|
- **Direct messages** — legal encryption scheme; server-side E2EE planned
|
||||||
- **Device management** — active sessions
|
- **Device management** — active sessions
|
||||||
@@ -18,15 +18,16 @@ FromChat is a 100% free and open-source messenger. This repository is the cross-
|
|||||||
|
|
||||||
## 📊 Client Comparison
|
## 📊 Client Comparison
|
||||||
|
|
||||||
⚠️ **iOS is temporarily not supported** (Apple constraints and development cost). It will ship later.
|
| Feature | Android | Desktop | Web | iOS |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **Messaging and profiles** | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| **Voice/video calls** | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| **Screen sharing** | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
| **Push when backgrounded** | ✅ (FCM) | ✅ (persistent WS + tray) | ✅ | ❌ (WS while open) |
|
||||||
|
| **Message reactions** | ❌ | ❌ | ✅ | ❌ |
|
||||||
|
| **Rich attachment support** | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
|
||||||
| Feature | Android | Web | iOS |
|
Desktop replaces the former Electron client in the Web repo. Run with `./gradlew :app:desktop:run`.
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| **Messaging and profiles** | ✅ | ✅ | ❌ |
|
|
||||||
| **Voice/video calls** | ✅ | ✅ | ❌ |
|
|
||||||
| **Screen sharing** | ✅ | ✅ | ❌ |
|
|
||||||
| **Message reactions** | ❌ | ✅ | ❌ |
|
|
||||||
| **Rich attachment support** | ✅ | ❌ | ❌ |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
# FromChat
|
# FromChat
|
||||||
|
|
||||||
FromChat — 100% бесплатный и открытый мессенджер. В этом репозитории — кроссплатформенный клиент (Android + iOS; iOS пока не готов).
|
FromChat — 100% бесплатный и открытый мессенджер. В этом репозитории — кроссплатформенный клиент (Android, Desktop на Compose Multiplatform и iOS).
|
||||||
|
|
||||||
[📥 Скачать](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram-канал](https://t.me/fromchat_ch) • [🖥️ Сервер](https://github.com/fromchat-messenger/backend)
|
[📥 Скачать](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram-канал](https://t.me/fromchat_ch) • [🖥️ Сервер](https://github.com/fromchat-messenger/backend)
|
||||||
|
|
||||||
## ✨ Возможности
|
## ✨ Возможности
|
||||||
|
|
||||||
- **Голосовые и видеозвонки** — LiveKit
|
- **Голосовые и видеозвонки** — LiveKit
|
||||||
- **Демонстрация экрана** во время звонков
|
- **Демонстрация экрана** во время звонков (Android)
|
||||||
- **Общий чат** — сообщество пользователей сервера
|
- **Общий чат** — сообщество пользователей сервера
|
||||||
- **Личные сообщения** — легальная схема шифрования; E2EE на сервере планируется
|
- **Личные сообщения** — легальная схема шифрования; E2EE на сервере планируется
|
||||||
- **Управление устройствами** — активные сеансы
|
- **Управление устройствами** — активные сеансы
|
||||||
@@ -18,15 +18,16 @@ FromChat — 100% бесплатный и открытый мессенджер.
|
|||||||
|
|
||||||
## 📊 Сравнение клиентов
|
## 📊 Сравнение клиентов
|
||||||
|
|
||||||
⚠️ **iOS временно не поддерживается** (ограничения Apple и объём работы). Клиент выйдет позже.
|
| Возможность | Android | Desktop | Web | iOS |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **Обмен сообщениями и профили** | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| **Голосовые/видеозвонки** | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| **Демонстрация экрана** | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
| **Push в фоне** | ✅ (FCM) | ✅ (постоянный WS + tray) | ✅ | ❌ (WS пока открыто) |
|
||||||
|
| **Реакции на сообщения** | ❌ | ❌ | ✅ | ❌ |
|
||||||
|
| **Расширенная поддержка вложений** | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
|
||||||
| Возможность | Android | Web | iOS |
|
Desktop заменяет бывший Electron-клиент в репозитории Web. Запуск: `./gradlew :app:desktop:run`.
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| **Обмен сообщениями и профили** | ✅ | ✅ | ❌ |
|
|
||||||
| **Голосовые/видеозвонки** | ✅ | ✅ | ❌ |
|
|
||||||
| **Демонстрация экрана** | ✅ | ✅ | ❌ |
|
|
||||||
| **Реакции на сообщения** | ❌ | ✅ | ❌ |
|
|
||||||
| **Расширенная поддержка вложений** | ✅ | ❌ | ❌ |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -27,6 +27,14 @@
|
|||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>FromChat needs microphone access for voice and video calls.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>FromChat needs camera access for video calls.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>FromChat needs photo library access so you can attach images to messages.</string>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>FromChat needs permission to save images you export from chats.</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ kotlin {
|
|||||||
freeCompilerArgs.addAll("-Xexpect-actual-classes")
|
freeCompilerArgs.addAll("-Xexpect-actual-classes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
|
||||||
listOf(
|
listOf(
|
||||||
iosArm64(),
|
iosArm64(),
|
||||||
iosSimulatorArm64(),
|
iosSimulatorArm64(),
|
||||||
@@ -107,8 +109,8 @@ kotlin {
|
|||||||
implementation(libs.navigation.compose)
|
implementation(libs.navigation.compose)
|
||||||
implementation(libs.compose.materialIconsExtended)
|
implementation(libs.compose.materialIconsExtended)
|
||||||
implementation(libs.haze)
|
implementation(libs.haze)
|
||||||
implementation(libs.haze.materials)
|
implementation(libs.haze.blur)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.haze.blur.materials)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
|
||||||
// Serialization
|
// Serialization
|
||||||
@@ -142,6 +144,7 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.markdown.renderer.m3)
|
implementation(libs.markdown.renderer.m3)
|
||||||
implementation(libs.bouncycastle.bcprov)
|
implementation(libs.bouncycastle.bcprov)
|
||||||
implementation(libs.androidx.exifinterface)
|
implementation(libs.androidx.exifinterface)
|
||||||
@@ -164,6 +167,37 @@ kotlin {
|
|||||||
implementation(libs.multiplatform.crypto.libsodium.bindings)
|
implementation(libs.multiplatform.crypto.libsodium.bindings)
|
||||||
implementation(libs.sqldelight.driver.native)
|
implementation(libs.sqldelight.driver.native)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jvmMain.dependencies {
|
||||||
|
implementation(libs.jetbrains.kotlinx.io.bytestring)
|
||||||
|
implementation(libs.jetbrains.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.multiplatform.crypto.libsodium.bindings)
|
||||||
|
implementation(libs.sqldelight.driver.sqlite)
|
||||||
|
implementation(libs.bouncycastle.bcprov)
|
||||||
|
// OpenJFX publishes empty jars without a classifier; declare every module
|
||||||
|
// with the host classifier and exclude transitive stubs.
|
||||||
|
val javafxVersion = libs.versions.openjfx.get()
|
||||||
|
val javafxClassifier = openjfxClassifier()
|
||||||
|
listOf("base", "graphics", "controls", "media", "web", "swing").forEach { module ->
|
||||||
|
implementation("org.openjfx:javafx-$module:$javafxVersion:$javafxClassifier") {
|
||||||
|
exclude(group = "org.openjfx")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openjfxClassifier(): String {
|
||||||
|
val os = System.getProperty("os.name").lowercase()
|
||||||
|
val arch = System.getProperty("os.arch").lowercase()
|
||||||
|
val isArm = arch == "aarch64" || arch == "arm64"
|
||||||
|
return when {
|
||||||
|
os.contains("mac") && isArm -> "mac-aarch64"
|
||||||
|
os.contains("mac") -> "mac"
|
||||||
|
os.contains("win") -> "win"
|
||||||
|
isArm -> "linux-aarch64"
|
||||||
|
else -> "linux"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.fromchat
|
||||||
|
|
||||||
|
actual fun keepWebSocketAliveInBackground(): Boolean = false
|
||||||
@@ -93,10 +93,10 @@ import androidx.compose.ui.zIndex
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
|
import dev.chrisbanes.haze.blur.materials.HazeMaterials
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import io.livekit.android.RoomOptions
|
import io.livekit.android.RoomOptions
|
||||||
import io.livekit.android.compose.local.RoomScope
|
import io.livekit.android.compose.local.RoomScope
|
||||||
@@ -275,7 +275,6 @@ private fun CallSlotSharedVideo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun CallMediaLayer(
|
actual fun CallMediaLayer(
|
||||||
connect: LiveKitConnectSession?,
|
connect: LiveKitConnectSession?,
|
||||||
@@ -551,7 +550,6 @@ actual fun CallMediaLayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CallRoomContent(
|
private fun CallRoomContent(
|
||||||
room: Room,
|
room: Room,
|
||||||
@@ -651,7 +649,7 @@ private fun CallRoomContent(
|
|||||||
val participants = rememberParticipants(room).value
|
val participants = rememberParticipants(room).value
|
||||||
val local = room.localParticipant
|
val local = room.localParticipant
|
||||||
val remote: Participant? = participants.firstOrNull { it != local }
|
val remote: Participant? = participants.firstOrNull { it != local }
|
||||||
val hazeState = rememberHazeState(blurEnabled = showInCallControls)
|
val hazeState = rememberHazeState()
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -1179,7 +1177,7 @@ private fun CallPreviewCluster(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun PreviewTile(
|
private fun PreviewTile(
|
||||||
room: Room,
|
room: Room,
|
||||||
@@ -1198,7 +1196,7 @@ private fun PreviewTile(
|
|||||||
val tileShape = RoundedCornerShape(16.dp)
|
val tileShape = RoundedCornerShape(16.dp)
|
||||||
// Only blur the *tile content* when the track isn't available yet.
|
// Only blur the *tile content* when the track isn't available yet.
|
||||||
// Do not blur the whole call scene; otherwise the opponent video stays blurred.
|
// Do not blur the whole call scene; otherwise the opponent video stays blurred.
|
||||||
val hazeState = rememberHazeState(blurEnabled = ref == null || !showVideo)
|
val hazeState = rememberHazeState()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(104.dp, 138.dp)
|
.size(104.dp, 138.dp)
|
||||||
@@ -1223,11 +1221,16 @@ private fun PreviewTile(
|
|||||||
innerClipShape = tileShape,
|
innerClipShape = tileShape,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
val placeholderHazeStyle = HazeMaterials.thick()
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
// Placeholder blur only for missing/disabled track.
|
// Placeholder blur only for missing/disabled track.
|
||||||
.hazeEffect(state = hazeState, style = HazeMaterials.thick())
|
.hazeEffect(state = hazeState) {
|
||||||
|
blurEffect {
|
||||||
|
style = placeholderHazeStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.55f)),
|
.background(MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.55f)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1246,7 +1249,6 @@ private fun PreviewTile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CallInlineControlBar(
|
private fun CallInlineControlBar(
|
||||||
room: Room,
|
room: Room,
|
||||||
@@ -1327,11 +1329,16 @@ private fun CallInlineControlBar(
|
|||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
val controlsHazeStyle = HazeMaterials.thick()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.86f)
|
.fillMaxWidth(0.86f)
|
||||||
.clip(RoundedCornerShape(28.dp))
|
.clip(RoundedCornerShape(28.dp))
|
||||||
.hazeEffect(state = hazeState, style = HazeMaterials.thick())
|
.hazeEffect(state = hazeState) {
|
||||||
|
blurEffect {
|
||||||
|
style = controlsHazeStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.48f)),
|
.background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.48f)),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.fromchat.ui.calls
|
||||||
|
|
||||||
|
internal actual fun notifyIncomingCall(title: String, body: String) = Unit
|
||||||
+3
-1
@@ -40,4 +40,6 @@ actual fun areAppNotificationsEnabled(): Boolean {
|
|||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun arePushNotificationsSupported(): Boolean = true
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
<string name="auth_captcha_body">Подтвердите, что вы человек, чтобы продолжить создание аккаунта.</string>
|
<string name="auth_captcha_body">Подтвердите, что вы человек, чтобы продолжить создание аккаунта.</string>
|
||||||
<string name="auth_captcha_failed">Не удалось пройти проверку. Попробуйте ещё раз.</string>
|
<string name="auth_captcha_failed">Не удалось пройти проверку. Попробуйте ещё раз.</string>
|
||||||
<string name="auth_yandex_webview_title">Яндекс ID</string>
|
<string name="auth_yandex_webview_title">Яндекс ID</string>
|
||||||
|
<string name="auth_yandex_browser_body">Завершите вход в браузере</string>
|
||||||
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
|
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
|
||||||
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
|
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
|
||||||
<string name="chats">Чаты</string>
|
<string name="chats">Чаты</string>
|
||||||
@@ -324,6 +325,7 @@
|
|||||||
<string name="settings_notifications_permission_required">Откройте системные настройки и разрешите уведомления.</string>
|
<string name="settings_notifications_permission_required">Откройте системные настройки и разрешите уведомления.</string>
|
||||||
<string name="settings_push_notifications">Push-уведомления</string>
|
<string name="settings_push_notifications">Push-уведомления</string>
|
||||||
<string name="settings_push_notifications_d">Они помогут вам узнать, если появилось новое сообщение пока приложение закрыто.</string>
|
<string name="settings_push_notifications_d">Они помогут вам узнать, если появилось новое сообщение пока приложение закрыто.</string>
|
||||||
|
<string name="settings_push_notifications_unavailable">Недоступны на этой платформе. Новые сообщения видны только пока приложение открыто.</string>
|
||||||
|
|
||||||
<string name="settings_devices_title">Устройства</string>
|
<string name="settings_devices_title">Устройства</string>
|
||||||
<string name="settings_devices_empty">Нет активных сессий</string>
|
<string name="settings_devices_empty">Нет активных сессий</string>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
<string name="auth_captcha_body">Confirm you’re human to continue creating your account.</string>
|
<string name="auth_captcha_body">Confirm you’re human to continue creating your account.</string>
|
||||||
<string name="auth_captcha_failed">Captcha verification failed. Please try again.</string>
|
<string name="auth_captcha_failed">Captcha verification failed. Please try again.</string>
|
||||||
<string name="auth_yandex_webview_title">Yandex ID</string>
|
<string name="auth_yandex_webview_title">Yandex ID</string>
|
||||||
|
<string name="auth_yandex_browser_body">Complete sign-in in your browser</string>
|
||||||
<string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string>
|
<string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string>
|
||||||
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
|
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
|
||||||
<!-- Main Screen -->
|
<!-- Main Screen -->
|
||||||
@@ -353,6 +354,7 @@
|
|||||||
<string name="settings_notifications_permission_required">Open system notification settings to allow alerts.</string>
|
<string name="settings_notifications_permission_required">Open system notification settings to allow alerts.</string>
|
||||||
<string name="settings_push_notifications">Push notifications</string>
|
<string name="settings_push_notifications">Push notifications</string>
|
||||||
<string name="settings_push_notifications_d">These will let you know if you got a new message when the app is closed.</string>
|
<string name="settings_push_notifications_d">These will let you know if you got a new message when the app is closed.</string>
|
||||||
|
<string name="settings_push_notifications_unavailable">Not available on this platform. New messages are only visible while the app is open.</string>
|
||||||
|
|
||||||
<string name="settings_devices_title">Devices</string>
|
<string name="settings_devices_title">Devices</string>
|
||||||
<string name="settings_devices_empty">No active sessions</string>
|
<string name="settings_devices_empty">No active sessions</string>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package ru.fromchat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When true, the WebSocket reconnect loop does not pause while the UI is not
|
||||||
|
* "started" (e.g. desktop window hidden to tray). Mobile keeps the existing
|
||||||
|
* foreground-gated behavior.
|
||||||
|
*/
|
||||||
|
expect fun keepWebSocketAliveInBackground(): Boolean
|
||||||
@@ -29,6 +29,7 @@ import kotlinx.serialization.json.Json
|
|||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import ru.fromchat.AppForeground
|
import ru.fromchat.AppForeground
|
||||||
|
import ru.fromchat.keepWebSocketAliveInBackground
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.UpdateSyncManager
|
import ru.fromchat.api.UpdateSyncManager
|
||||||
@@ -117,6 +118,7 @@ object WebSocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun awaitForeground() {
|
private suspend fun awaitForeground() {
|
||||||
|
if (keepWebSocketAliveInBackground()) return
|
||||||
if (AppForeground.isInForeground.value) return
|
if (AppForeground.isInForeground.value) return
|
||||||
AppForeground.isInForeground.first { it }
|
AppForeground.isInForeground.first { it }
|
||||||
}
|
}
|
||||||
@@ -369,7 +371,7 @@ object WebSocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onNetworkAvailable() {
|
fun onNetworkAvailable() {
|
||||||
if (!AppForeground.isInForeground.value) return
|
if (!keepWebSocketAliveInBackground() && !AppForeground.isInForeground.value) return
|
||||||
|
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
val prev = lastOnNetworkAvailableWallMs
|
val prev = lastOnNetworkAvailableWallMs
|
||||||
@@ -393,7 +395,7 @@ object WebSocketManager {
|
|||||||
* Restarts the reconnect loop immediately instead of waiting out exponential backoff.
|
* Restarts the reconnect loop immediately instead of waiting out exponential backoff.
|
||||||
*/
|
*/
|
||||||
fun onServerLikelyReachable() {
|
fun onServerLikelyReachable() {
|
||||||
if (!AppForeground.isInForeground.value) return
|
if (!keepWebSocketAliveInBackground() && !AppForeground.isInForeground.value) return
|
||||||
if (session != null) return
|
if (session != null) return
|
||||||
if (ApiClient.token.isNullOrEmpty()) return
|
if (ApiClient.token.isNullOrEmpty()) return
|
||||||
|
|
||||||
|
|||||||
+16
-29
@@ -30,36 +30,25 @@ object PublicChatProfileCache {
|
|||||||
*/
|
*/
|
||||||
fun hydrateFromDiskImmediate(instanceId: String): PublicChatProfile? {
|
fun hydrateFromDiskImmediate(instanceId: String): PublicChatProfile? {
|
||||||
val id = instanceId.trim()
|
val id = instanceId.trim()
|
||||||
synchronized(this) {
|
if (id.isEmpty()) return _profile.value
|
||||||
if (id.isEmpty()) {
|
if (loadedInstanceId == id && isCompleteProfile(_profile.value)) return _profile.value
|
||||||
return _profile.value
|
val previousInstanceId = loadedInstanceId
|
||||||
}
|
loadedInstanceId = id
|
||||||
if (loadedInstanceId == id && isCompleteProfile(_profile.value)) {
|
val disk = PublicChatProfileCacheStore.getImmediate(id)
|
||||||
return _profile.value
|
_profile.value = when {
|
||||||
}
|
disk != null -> disk
|
||||||
val previousInstanceId = loadedInstanceId
|
previousInstanceId.isEmpty() -> _profile.value
|
||||||
loadedInstanceId = id
|
previousInstanceId != id -> null
|
||||||
val disk = PublicChatProfileCacheStore.getImmediate(id)
|
else -> _profile.value
|
||||||
_profile.value = when {
|
|
||||||
disk != null -> disk
|
|
||||||
previousInstanceId.isEmpty() -> _profile.value
|
|
||||||
previousInstanceId != id -> null
|
|
||||||
else -> _profile.value
|
|
||||||
}
|
|
||||||
return _profile.value
|
|
||||||
}
|
}
|
||||||
|
return _profile.value
|
||||||
}
|
}
|
||||||
|
|
||||||
fun put(profile: PublicChatProfile) {
|
fun put(profile: PublicChatProfile) {
|
||||||
val instanceId: String
|
_profile.value = profile
|
||||||
synchronized(this) {
|
val instanceId = resolvedInstanceId()
|
||||||
_profile.value = profile
|
|
||||||
instanceId = resolvedInstanceId()
|
|
||||||
if (instanceId.isNotEmpty()) {
|
|
||||||
loadedInstanceId = instanceId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (instanceId.isNotEmpty()) {
|
if (instanceId.isNotEmpty()) {
|
||||||
|
loadedInstanceId = instanceId
|
||||||
runCatching { PublicChatProfileCacheStore.putImmediate(instanceId, profile) }
|
runCatching { PublicChatProfileCacheStore.putImmediate(instanceId, profile) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,10 +62,8 @@ object PublicChatProfileCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun clear() {
|
suspend fun clear() {
|
||||||
synchronized(this) {
|
_profile.value = null
|
||||||
_profile.value = null
|
loadedInstanceId = ""
|
||||||
loadedInstanceId = ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolvedInstanceId(): String =
|
private fun resolvedInstanceId(): String =
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.pr0gramm3r101.utils.ToggleNavScrimEffect
|
import com.pr0gramm3r101.utils.ToggleNavScrimEffect
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
@@ -101,7 +102,7 @@ fun DocumentScreen(
|
|||||||
) {
|
) {
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val hazeState = rememberHazeState(blurEnabled = true)
|
val hazeState = rememberHazeState()
|
||||||
|
|
||||||
LaunchedEffect(type) {
|
LaunchedEffect(type) {
|
||||||
listState.scrollToItem(0)
|
listState.scrollToItem(0)
|
||||||
@@ -116,6 +117,7 @@ fun DocumentScreen(
|
|||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
contentWindowInsets = WindowInsets.navigationBars,
|
contentWindowInsets = WindowInsets.navigationBars,
|
||||||
topBar = {
|
topBar = {
|
||||||
|
val topBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
@@ -141,10 +143,11 @@ fun DocumentScreen(
|
|||||||
),
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
|
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
|
||||||
.hazeEffect(
|
.hazeEffect(state = hazeState) {
|
||||||
state = hazeState,
|
blurEffect {
|
||||||
style = rememberChatSurfaceContainerHazeStyle(),
|
style = topBarHazeStyle
|
||||||
),
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -52,8 +53,6 @@ object AppLogStore {
|
|||||||
private var loadedFromDisk = false
|
private var loadedFromDisk = false
|
||||||
private var nextEntryId = 0L
|
private var nextEntryId = 0L
|
||||||
|
|
||||||
private val writeLock = Any()
|
|
||||||
|
|
||||||
fun record(
|
fun record(
|
||||||
level: AppLogLevel,
|
level: AppLogLevel,
|
||||||
tag: String,
|
tag: String,
|
||||||
@@ -68,8 +67,14 @@ object AppLogStore {
|
|||||||
message = message,
|
message = message,
|
||||||
stackTrace = throwable?.stackTraceToString(),
|
stackTrace = throwable?.stackTraceToString(),
|
||||||
)
|
)
|
||||||
synchronized(writeLock) {
|
if (mutex.tryLock()) {
|
||||||
appendEntry(entry)
|
try {
|
||||||
|
appendEntry(entry)
|
||||||
|
} finally {
|
||||||
|
mutex.unlock()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
runBlocking { mutex.withLock { appendEntry(entry) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ import androidx.compose.animation.fadeOut
|
|||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
@@ -24,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.navigation.NamedNavArgument
|
import androidx.navigation.NamedNavArgument
|
||||||
@@ -33,6 +38,7 @@ import androidx.navigation.NavGraphBuilder
|
|||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
@@ -41,8 +47,11 @@ import coil3.network.ktor3.KtorNetworkFetcherFactory
|
|||||||
import coil3.svg.SvgDecoder
|
import coil3.svg.SvgDecoder
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import com.pr0gramm3r101.utils.LocalSystemBarsVisibility
|
import com.pr0gramm3r101.utils.LocalSystemBarsVisibility
|
||||||
|
import com.pr0gramm3r101.utils.WindowWidthSizeClass
|
||||||
|
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
|
||||||
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
|
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
|
||||||
import com.pr0gramm3r101.utils.rememberSystemBarsController
|
import com.pr0gramm3r101.utils.rememberSystemBarsController
|
||||||
|
import com.pr0gramm3r101.utils.widthSizeClass
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -55,6 +64,7 @@ import kotlinx.serialization.json.jsonObject
|
|||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import ru.fromchat.AppForeground
|
import ru.fromchat.AppForeground
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
|
import ru.fromchat.keepWebSocketAliveInBackground
|
||||||
import ru.fromchat.api.DeferredStartupNetwork
|
import ru.fromchat.api.DeferredStartupNetwork
|
||||||
import ru.fromchat.api.ProfileUpdateSync
|
import ru.fromchat.api.ProfileUpdateSync
|
||||||
import ru.fromchat.api.PublicChatProfileSync
|
import ru.fromchat.api.PublicChatProfileSync
|
||||||
@@ -116,6 +126,10 @@ import ru.fromchat.utils.NetworkConnectivity
|
|||||||
|
|
||||||
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
|
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
|
||||||
|
|
||||||
|
private fun isConversationChatRoute(route: String?): Boolean =
|
||||||
|
route == PublicChatNav.CHAT_ROUTE ||
|
||||||
|
(route != null && route.startsWith("dm/") && "/chat" in route)
|
||||||
|
|
||||||
private val rootNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
|
private val rootNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||||
|
|
||||||
private fun rootNavEnterTransition(): EnterTransition =
|
private fun rootNavEnterTransition(): EnterTransition =
|
||||||
@@ -281,9 +295,14 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]).
|
// Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]).
|
||||||
|
// Desktop keeps the socket alive while the process runs (tray / hidden window).
|
||||||
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
fun syncForeground() {
|
fun syncForeground() {
|
||||||
|
if (keepWebSocketAliveInBackground()) {
|
||||||
|
AppForeground.setForeground(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
AppForeground.setForeground(
|
AppForeground.setForeground(
|
||||||
lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||||
)
|
)
|
||||||
@@ -301,7 +320,11 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Lifecycle.Event.ON_STOP -> AppForeground.setForeground(false)
|
Lifecycle.Event.ON_STOP -> {
|
||||||
|
if (!keepWebSocketAliveInBackground()) {
|
||||||
|
AppForeground.setForeground(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,16 +436,37 @@ fun App(
|
|||||||
LocalSystemBarsVisibility provides rememberSystemBarsController()
|
LocalSystemBarsVisibility provides rememberSystemBarsController()
|
||||||
) {
|
) {
|
||||||
if (startDestination != null) {
|
if (startDestination != null) {
|
||||||
|
val currentEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val showConversationListDetail =
|
||||||
|
currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.EXPANDED &&
|
||||||
|
isConversationChatRoute(currentEntry?.destination?.route)
|
||||||
|
|
||||||
ScreenSurface {
|
ScreenSurface {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
NavHost(
|
Row(Modifier.fillMaxSize()) {
|
||||||
navController = navController,
|
if (showConversationListDetail) {
|
||||||
startDestination = startDestination!!,
|
Box(
|
||||||
enterTransition = { rootNavEnterTransition() },
|
modifier = Modifier
|
||||||
exitTransition = { rootNavExitTransition() },
|
.width(360.dp)
|
||||||
popEnterTransition = { rootNavPopEnterTransition() },
|
.fillMaxHeight(),
|
||||||
popExitTransition = { rootNavPopExitTransition() },
|
) {
|
||||||
) {
|
MainScreen(
|
||||||
|
sharedTransitionScope = this@SharedTransitionLayout,
|
||||||
|
snackbarHostState = profileLookupSnackbarHostState,
|
||||||
|
listPaneOnly = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
VerticalDivider()
|
||||||
|
}
|
||||||
|
Box(Modifier.weight(1f).fillMaxSize()) {
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = startDestination!!,
|
||||||
|
enterTransition = { rootNavEnterTransition() },
|
||||||
|
exitTransition = { rootNavExitTransition() },
|
||||||
|
popEnterTransition = { rootNavPopEnterTransition() },
|
||||||
|
popExitTransition = { rootNavPopExitTransition() },
|
||||||
|
) {
|
||||||
composable("serverConfig") {
|
composable("serverConfig") {
|
||||||
ServerConfigScreen()
|
ServerConfigScreen()
|
||||||
}
|
}
|
||||||
@@ -574,7 +618,7 @@ fun App(
|
|||||||
),
|
),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val focusField = EditProfileFocusField.fromArg(
|
val focusField = EditProfileFocusField.fromArg(
|
||||||
entry.arguments?.getString(ProfileRoutes.ARG_FOCUS),
|
entry.savedStateHandle.get<String>(ProfileRoutes.ARG_FOCUS),
|
||||||
)
|
)
|
||||||
EditProfileScreen(
|
EditProfileScreen(
|
||||||
onBack = { navController.navigateUp() },
|
onBack = { navController.navigateUp() },
|
||||||
@@ -733,8 +777,10 @@ fun App(
|
|||||||
ApiClient.onAuthError = null
|
ApiClient.onAuthError = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CallOverlay(Modifier.fillMaxSize())
|
CallOverlay(Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package ru.fromchat.ui.calls
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared LiveKit JS page for iOS [WKWebView] and desktop JavaFX [WebView].
|
||||||
|
* Native LiveKit SDKs are used on Android; desktop has no JVM SDK.
|
||||||
|
*/
|
||||||
|
internal object LiveKitCallWebPage {
|
||||||
|
private const val LIVEKIT_CDN =
|
||||||
|
"https://cdn.jsdelivr.net/npm/livekit-client@2.15.4/dist/livekit-client.umd.min.js"
|
||||||
|
|
||||||
|
val html: String = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0; width: 100%; height: 100%;
|
||||||
|
background: #121212; overflow: hidden; font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
#stage { position: relative; width: 100%; height: 100%; background: #000; }
|
||||||
|
#remoteVideo, #remoteAudioHost video {
|
||||||
|
position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: #000;
|
||||||
|
}
|
||||||
|
#localVideo {
|
||||||
|
position: absolute; right: 12px; top: 12px; width: 28%; max-width: 160px;
|
||||||
|
aspect-ratio: 3/4; object-fit: cover; border-radius: 12px; background: #333;
|
||||||
|
z-index: 2; transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
position: absolute; left: 16px; right: 16px; top: 42%;
|
||||||
|
text-align: center; color: #e8e8e8; font-size: 15px; z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#remoteAudioHost { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="stage">
|
||||||
|
<video id="remoteVideo" autoplay playsinline></video>
|
||||||
|
<video id="localVideo" autoplay playsinline muted></video>
|
||||||
|
<div id="remoteAudioHost"></div>
|
||||||
|
<div id="status">Connecting…</div>
|
||||||
|
</div>
|
||||||
|
<script src="$LIVEKIT_CDN"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var LK = window.LivekitClient;
|
||||||
|
var room = null;
|
||||||
|
var statusEl = document.getElementById("status");
|
||||||
|
var remoteVideo = document.getElementById("remoteVideo");
|
||||||
|
var localVideo = document.getElementById("localVideo");
|
||||||
|
var remoteAudioHost = document.getElementById("remoteAudioHost");
|
||||||
|
|
||||||
|
function setStatus(text) {
|
||||||
|
if (statusEl) statusEl.textContent = text || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyNative(method, message) {
|
||||||
|
try {
|
||||||
|
if (window.FromChatNative && typeof window.FromChatNative[method] === "function") {
|
||||||
|
window.FromChatNative[method](message || "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.FromChatCall) {
|
||||||
|
window.webkit.messageHandlers.FromChatCall.postMessage({
|
||||||
|
method: method,
|
||||||
|
message: message || ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e2) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachRemoteTrack(track) {
|
||||||
|
if (!track) return;
|
||||||
|
if (track.kind === "video") {
|
||||||
|
track.attach(remoteVideo);
|
||||||
|
setStatus("");
|
||||||
|
} else if (track.kind === "audio") {
|
||||||
|
var el = track.attach();
|
||||||
|
remoteAudioHost.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachAll() {
|
||||||
|
try {
|
||||||
|
if (room) {
|
||||||
|
room.remoteParticipants.forEach(function (p) {
|
||||||
|
p.trackPublications.forEach(function (pub) {
|
||||||
|
if (pub.track) pub.track.detach();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
remoteAudioHost.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(url, token) {
|
||||||
|
if (!LK) {
|
||||||
|
setStatus("LiveKit failed to load");
|
||||||
|
notifyNative("onConnectFailed", "LiveKit client failed to load");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (room) {
|
||||||
|
await room.disconnect();
|
||||||
|
room = null;
|
||||||
|
}
|
||||||
|
detachAll();
|
||||||
|
setStatus("Connecting…");
|
||||||
|
room = new LK.Room({ adaptiveStream: true, dynacast: true });
|
||||||
|
room.on(LK.RoomEvent.TrackSubscribed, function (track) {
|
||||||
|
attachRemoteTrack(track);
|
||||||
|
});
|
||||||
|
room.on(LK.RoomEvent.TrackUnsubscribed, function (track) {
|
||||||
|
try { track.detach(); } catch (e) {}
|
||||||
|
});
|
||||||
|
room.on(LK.RoomEvent.Disconnected, function () {
|
||||||
|
setStatus("");
|
||||||
|
});
|
||||||
|
room.on(LK.RoomEvent.MediaDevicesError, function (e) {
|
||||||
|
notifyNative("onConnectFailed", (e && e.message) ? e.message : "Media device error");
|
||||||
|
});
|
||||||
|
await room.connect(url, token);
|
||||||
|
await room.localParticipant.setMicrophoneEnabled(true);
|
||||||
|
try {
|
||||||
|
await room.localParticipant.setCameraEnabled(true);
|
||||||
|
} catch (camErr) {
|
||||||
|
// Audio-only is fine if camera is denied / unavailable.
|
||||||
|
}
|
||||||
|
var camPub = room.localParticipant.getTrackPublication(LK.Track.Source.Camera);
|
||||||
|
if (camPub && camPub.track) {
|
||||||
|
camPub.track.attach(localVideo);
|
||||||
|
}
|
||||||
|
room.remoteParticipants.forEach(function (p) {
|
||||||
|
p.trackPublications.forEach(function (pub) {
|
||||||
|
if (pub.isSubscribed && pub.track) attachRemoteTrack(pub.track);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setStatus("");
|
||||||
|
notifyNative("onConnected", "");
|
||||||
|
} catch (err) {
|
||||||
|
var msg = (err && err.message) ? err.message : String(err);
|
||||||
|
setStatus(msg);
|
||||||
|
notifyNative("onConnectFailed", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.FromChatCallCtl = {
|
||||||
|
setMicEnabled: function (on) {
|
||||||
|
if (!room) return Promise.resolve();
|
||||||
|
return room.localParticipant.setMicrophoneEnabled(!!on);
|
||||||
|
},
|
||||||
|
setCamEnabled: function (on) {
|
||||||
|
if (!room) return Promise.resolve();
|
||||||
|
return room.localParticipant.setCameraEnabled(!!on).then(function () {
|
||||||
|
var camPub = room.localParticipant.getTrackPublication(LK.Track.Source.Camera);
|
||||||
|
if (on && camPub && camPub.track) {
|
||||||
|
camPub.track.attach(localVideo);
|
||||||
|
} else if (!on) {
|
||||||
|
try { localVideo.srcObject = null; } catch (e) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
disconnect: function () {
|
||||||
|
if (!room) return Promise.resolve();
|
||||||
|
var r = room;
|
||||||
|
room = null;
|
||||||
|
detachAll();
|
||||||
|
return r.disconnect();
|
||||||
|
},
|
||||||
|
connect: connect
|
||||||
|
};
|
||||||
|
|
||||||
|
window.fromChatConnect = connect;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
fun connectScript(serverUrl: String, token: String): String {
|
||||||
|
val url = jsonStringLiteral(serverUrl)
|
||||||
|
val tok = jsonStringLiteral(token)
|
||||||
|
return "window.fromChatConnect($url, $tok);"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMicScript(enabled: Boolean): String =
|
||||||
|
"window.FromChatCallCtl && window.FromChatCallCtl.setMicEnabled(${if (enabled) "true" else "false"});"
|
||||||
|
|
||||||
|
fun setCamScript(enabled: Boolean): String =
|
||||||
|
"window.FromChatCallCtl && window.FromChatCallCtl.setCamEnabled(${if (enabled) "true" else "false"});"
|
||||||
|
|
||||||
|
fun disconnectScript(): String =
|
||||||
|
"window.FromChatCallCtl && window.FromChatCallCtl.disconnect();"
|
||||||
|
|
||||||
|
private fun jsonStringLiteral(value: String): String = buildString {
|
||||||
|
append('"')
|
||||||
|
value.forEach { ch ->
|
||||||
|
when (ch) {
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
'"' -> append("\\\"")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> append("\\r")
|
||||||
|
'\t' -> append("\\t")
|
||||||
|
else -> {
|
||||||
|
if (ch.code < 0x20) {
|
||||||
|
append("\\u")
|
||||||
|
append(ch.code.toString(16).padStart(4, '0'))
|
||||||
|
} else {
|
||||||
|
append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
append('"')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package ru.fromchat.ui.calls
|
||||||
|
|
||||||
|
/** Best-effort OS notification for an incoming call (desktop tray; no-op elsewhere). */
|
||||||
|
internal expect fun notifyIncomingCall(title: String, body: String)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package ru.fromchat.ui.calls
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CallEnd
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.MicOff
|
||||||
|
import androidx.compose.material.icons.filled.Videocam
|
||||||
|
import androidx.compose.material.icons.filled.VideocamOff
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.cd_call_camera
|
||||||
|
import ru.fromchat.cd_call_end
|
||||||
|
import ru.fromchat.cd_call_mic
|
||||||
|
|
||||||
|
/** Mic / camera / end controls for WebView-based LiveKit call UIs (iOS + desktop). */
|
||||||
|
@Composable
|
||||||
|
internal fun WebCallInCallControls(
|
||||||
|
micOn: Boolean,
|
||||||
|
camOn: Boolean,
|
||||||
|
onMicToggle: () -> Unit,
|
||||||
|
onCamToggle: () -> Unit,
|
||||||
|
onEndCall: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 20.dp, vertical = 28.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onMicToggle,
|
||||||
|
modifier = Modifier.size(52.dp),
|
||||||
|
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
|
containerColor = if (micOn) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
},
|
||||||
|
contentColor = if (micOn) {
|
||||||
|
MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (micOn) Icons.Filled.Mic else Icons.Filled.MicOff,
|
||||||
|
contentDescription = stringResource(Res.string.cd_call_mic),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(10.dp))
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onCamToggle,
|
||||||
|
modifier = Modifier.size(52.dp),
|
||||||
|
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
|
containerColor = if (camOn) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
},
|
||||||
|
contentColor = if (camOn) {
|
||||||
|
MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (camOn) Icons.Filled.Videocam else Icons.Filled.VideocamOff,
|
||||||
|
contentDescription = stringResource(Res.string.cd_call_camera),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(18.dp))
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = onEndCall,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.CallEnd,
|
||||||
|
contentDescription = stringResource(Res.string.cd_call_end),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
@file:OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
|
|
||||||
package ru.fromchat.ui.chat
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
@@ -68,10 +66,10 @@ import androidx.compose.ui.unit.IntSize
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.pr0gramm3r101.utils.crypto.Base64
|
import com.pr0gramm3r101.utils.crypto.Base64
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
|
import dev.chrisbanes.haze.blur.materials.HazeMaterials
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -122,7 +120,6 @@ internal fun isImageFilename(name: String): Boolean =
|
|||||||
name.endsWith(".png", true) || name.endsWith(".jpg", true) ||
|
name.endsWith(".png", true) || name.endsWith(".jpg", true) ||
|
||||||
name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true)
|
name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true)
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AttachmentPreview(
|
fun AttachmentPreview(
|
||||||
file: DmFile?,
|
file: DmFile?,
|
||||||
@@ -313,7 +310,7 @@ fun AttachmentPreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatImageTileContent(
|
private fun ChatImageTileContent(
|
||||||
messageId: Int,
|
messageId: Int,
|
||||||
@@ -458,7 +455,8 @@ private fun ChatImageTileContent(
|
|||||||
|
|
||||||
val imageContentScale = ContentScale.Crop
|
val imageContentScale = ContentScale.Crop
|
||||||
val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f)
|
val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f)
|
||||||
val thumbHazeState = rememberHazeState(blurEnabled = true)
|
val thumbHazeState = rememberHazeState()
|
||||||
|
val thumbOverlayHazeStyle = HazeMaterials.thin()
|
||||||
|
|
||||||
LaunchedEffect(messageId, fileIndex, cacheClientId) {
|
LaunchedEffect(messageId, fileIndex, cacheClientId) {
|
||||||
DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri ->
|
DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri ->
|
||||||
@@ -793,10 +791,11 @@ private fun ChatImageTileContent(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.hazeEffect(
|
.hazeEffect(state = thumbHazeState) {
|
||||||
state = thumbHazeState,
|
blurEffect {
|
||||||
style = HazeMaterials.thin(),
|
style = thumbOverlayHazeStyle
|
||||||
),
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -918,7 +917,6 @@ private fun ChatImageTileContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DownloadCancelledImageOverlay(
|
private fun DownloadCancelledImageOverlay(
|
||||||
isAuthor: Boolean,
|
isAuthor: Boolean,
|
||||||
@@ -926,9 +924,14 @@ private fun DownloadCancelledImageOverlay(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scrim = MaterialTheme.colorScheme.scrim.copy(alpha = 0.38f)
|
val scrim = MaterialTheme.colorScheme.scrim.copy(alpha = 0.38f)
|
||||||
|
val overlayHazeStyle = HazeMaterials.thin()
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.hazeEffect(style = HazeMaterials.thin())
|
.hazeEffect {
|
||||||
|
blurEffect {
|
||||||
|
style = overlayHazeStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.12f)),
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.12f)),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@@ -958,7 +961,6 @@ private fun DownloadCancelledImageOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun UploadingImageOverlay(
|
private fun UploadingImageOverlay(
|
||||||
model: String?,
|
model: String?,
|
||||||
@@ -969,12 +971,17 @@ private fun UploadingImageOverlay(
|
|||||||
onCancelUpload: (() -> Unit)? = null,
|
onCancelUpload: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val overlayHazeStyle = HazeMaterials.thin()
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.matchParentSize()
|
.matchParentSize()
|
||||||
.clip(clipShape)
|
.clip(clipShape)
|
||||||
.hazeEffect(style = HazeMaterials.thin())
|
.hazeEffect {
|
||||||
|
blurEffect {
|
||||||
|
style = overlayHazeStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
previewBitmap != null -> {
|
previewBitmap != null -> {
|
||||||
@@ -1084,7 +1091,6 @@ internal fun ExpressiveUploadIndicator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PendingImageContent(
|
private fun PendingImageContent(
|
||||||
uri: String,
|
uri: String,
|
||||||
@@ -1260,10 +1266,15 @@ private fun CorruptedImagePlaceholder(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
clipShape: RoundedCornerShape = attachmentImageCornerShape(isAuthor = false),
|
clipShape: RoundedCornerShape = attachmentImageCornerShape(isAuthor = false),
|
||||||
) {
|
) {
|
||||||
|
val overlayHazeStyle = HazeMaterials.thin()
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.clip(clipShape)
|
.clip(clipShape)
|
||||||
.hazeEffect(style = HazeMaterials.thin()),
|
.hazeEffect {
|
||||||
|
blurEffect {
|
||||||
|
style = overlayHazeStyle
|
||||||
|
}
|
||||||
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -244,6 +245,7 @@ fun ChatInput(
|
|||||||
isReadOnly: Boolean = false,
|
isReadOnly: Boolean = false,
|
||||||
onReadOnlyMessageClick: () -> Unit = {},
|
onReadOnlyMessageClick: () -> Unit = {},
|
||||||
snackbarHostState: SnackbarHostState? = null,
|
snackbarHostState: SnackbarHostState? = null,
|
||||||
|
hazeBlurEnabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val composerHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
val composerHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -313,7 +315,12 @@ fun ChatInput(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(pillShape)
|
.clip(pillShape)
|
||||||
.hazeEffect(state = hazeState, style = composerHazeStyle)
|
.hazeEffect(state = hazeState) {
|
||||||
|
blurEffect {
|
||||||
|
blurEnabled = hazeBlurEnabled
|
||||||
|
style = composerHazeStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
.clickable(enabled = true) { onReadOnlyMessageClick() },
|
.clickable(enabled = true) { onReadOnlyMessageClick() },
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -344,7 +351,12 @@ fun ChatInput(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer, pillShape)
|
.background(MaterialTheme.colorScheme.surfaceContainer, pillShape)
|
||||||
.clip(pillShape)
|
.clip(pillShape)
|
||||||
.hazeEffect(state = hazeState, style = composerHazeStyle),
|
.hazeEffect(state = hazeState) {
|
||||||
|
blurEffect {
|
||||||
|
blurEnabled = hazeBlurEnabled
|
||||||
|
style = composerHazeStyle
|
||||||
|
}
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
AnimatedPreviewBar(replyTo) { reply ->
|
AnimatedPreviewBar(replyTo) { reply ->
|
||||||
val replySubtitle = if (reply.isContentCorrupted) {
|
val replySubtitle = if (reply.isContentCorrupted) {
|
||||||
@@ -550,10 +562,14 @@ fun ChatInput(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(actionBackground)
|
.background(actionBackground)
|
||||||
.hazeEffect(state = hazeState, style = composerHazeStyle) {
|
.hazeEffect(state = hazeState) {
|
||||||
// Keep the node in the tree in both directions; fading alpha avoids the
|
blurEffect {
|
||||||
// frosted layer popping on over the color when send -> voice.
|
// Keep the node in the tree in both directions; fading alpha avoids the
|
||||||
alpha = hazeOverlayAlpha
|
// frosted layer popping on over the color when send -> voice.
|
||||||
|
blurEnabled = hazeBlurEnabled
|
||||||
|
style = composerHazeStyle
|
||||||
|
alpha = hazeOverlayAlpha
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
|
|||||||
@@ -79,11 +79,11 @@ import ru.fromchat.utils.rememberRegistrationDateFormatStrings
|
|||||||
import com.pr0gramm3r101.utils.resetFocus
|
import com.pr0gramm3r101.utils.resetFocus
|
||||||
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
|
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.HazeProgressive
|
import dev.chrisbanes.haze.blur.HazeProgressive
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
|
import dev.chrisbanes.haze.blur.materials.HazeMaterials
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -157,7 +157,7 @@ import ru.fromchat.utils.rememberLastSeenFormatStrings
|
|||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
panel: ChatPanel,
|
panel: ChatPanel,
|
||||||
@@ -426,9 +426,8 @@ fun ChatScreen(
|
|||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val profileUserId = panelState.profileUserId
|
val profileUserId = panelState.profileUserId
|
||||||
val hazeState = rememberHazeState(
|
val hazeState = rememberHazeState()
|
||||||
blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
|
val hazeBlurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
|
||||||
)
|
|
||||||
|
|
||||||
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
|
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
|
||||||
val statusMap by UserStatusStore.status.collectAsState()
|
val statusMap by UserStatusStore.status.collectAsState()
|
||||||
@@ -816,16 +815,21 @@ fun ChatScreen(
|
|||||||
// floating header Box only; avoids extra top inset on the fade / list and duplicate “padding”.
|
// floating header Box only; avoids extra top inset on the fade / list and duplicate “padding”.
|
||||||
contentWindowInsets = WindowInsets.navigationBars,
|
contentWindowInsets = WindowInsets.navigationBars,
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
|
val bottomBarHazeStyle = HazeMaterials.thin()
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.windowInsetsPadding(WindowInsets.ime)
|
.windowInsetsPadding(WindowInsets.ime)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
.hazeEffect(state = hazeState) {
|
||||||
progressive = HazeProgressive.verticalGradient(
|
blurEffect {
|
||||||
startIntensity = 0f,
|
blurEnabled = hazeBlurEnabled
|
||||||
endIntensity = 1f,
|
style = bottomBarHazeStyle
|
||||||
)
|
progressive = HazeProgressive.verticalGradient(
|
||||||
|
startIntensity = 0f,
|
||||||
|
endIntensity = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
if (peerDeleted && dmRecipientId != null) {
|
if (peerDeleted && dmRecipientId != null) {
|
||||||
@@ -1045,6 +1049,7 @@ fun ChatScreen(
|
|||||||
hazeState = hazeState,
|
hazeState = hazeState,
|
||||||
supportsAttachments = panel.supportsAttachments,
|
supportsAttachments = panel.supportsAttachments,
|
||||||
isReadOnly = isReadOnly,
|
isReadOnly = isReadOnly,
|
||||||
|
hazeBlurEnabled = hazeBlurEnabled,
|
||||||
onReadOnlyMessageClick = {
|
onReadOnlyMessageClick = {
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
showSuspendedSupportSheet = true
|
showSuspendedSupportSheet = true
|
||||||
@@ -1383,11 +1388,13 @@ fun ChatScreen(
|
|||||||
},
|
},
|
||||||
contentDescription = scrollToBottomCd,
|
contentDescription = scrollToBottomCd,
|
||||||
hazeState = hazeState,
|
hazeState = hazeState,
|
||||||
|
hazeBlurEnabled = hazeBlurEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatTopBar(
|
ChatTopBar(
|
||||||
hazeState = hazeState,
|
hazeState = hazeState,
|
||||||
|
hazeBlurEnabled = hazeBlurEnabled,
|
||||||
onBack = {
|
onBack = {
|
||||||
runNav { navController.navigateUp() }
|
runNav { navController.navigateUp() }
|
||||||
},
|
},
|
||||||
@@ -1583,6 +1590,7 @@ private fun ChatScrollToBottomButton(
|
|||||||
contentDescription: String,
|
contentDescription: String,
|
||||||
hazeState: HazeState,
|
hazeState: HazeState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
hazeBlurEnabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
|
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||||
Box(
|
Box(
|
||||||
@@ -1598,7 +1606,12 @@ private fun ChatScrollToBottomButton(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
|
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
|
||||||
.hazeEffect(state = hazeState, style = hazeStyle),
|
.hazeEffect(state = hazeState) {
|
||||||
|
blurEffect {
|
||||||
|
blurEnabled = hazeBlurEnabled
|
||||||
|
style = hazeStyle
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.KeyboardArrowDown,
|
imageVector = Icons.Rounded.KeyboardArrowDown,
|
||||||
|
|||||||
@@ -62,8 +62,9 @@ import androidx.compose.ui.unit.LayoutDirection
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.pr0gramm3r101.utils.conditional
|
import com.pr0gramm3r101.utils.conditional
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.HazeStyle
|
import dev.chrisbanes.haze.blur.HazeBlurStyle
|
||||||
import dev.chrisbanes.haze.HazeTint
|
import dev.chrisbanes.haze.blur.HazeColorEffect
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
@@ -325,6 +326,7 @@ fun ChatTopBar(
|
|||||||
callContentDescription: String,
|
callContentDescription: String,
|
||||||
titleChrome: @Composable () -> Unit,
|
titleChrome: @Composable () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
hazeBlurEnabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val bottomCornerRadius = ChatFloatingHeaderBottomArcRadius
|
val bottomCornerRadius = ChatFloatingHeaderBottomArcRadius
|
||||||
// [BottomInsetTopBarShape] draws the arc in the strip from y = (content height) .. (content + r);
|
// [BottomInsetTopBarShape] draws the arc in the strip from y = (content height) .. (content + r);
|
||||||
@@ -370,6 +372,7 @@ fun ChatTopBar(
|
|||||||
val layoutHeight = topBarPlaceable.height + arcExtentPx
|
val layoutHeight = topBarPlaceable.height + arcExtentPx
|
||||||
|
|
||||||
val bgPlaceable = subcompose("background") {
|
val bgPlaceable = subcompose("background") {
|
||||||
|
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -379,7 +382,12 @@ fun ChatTopBar(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
|
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
|
||||||
.hazeEffect(state = hazeState, style = rememberChatSurfaceContainerHazeStyle()),
|
.hazeEffect(state = hazeState) {
|
||||||
|
blurEffect {
|
||||||
|
blurEnabled = hazeBlurEnabled
|
||||||
|
style = hazeStyle
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}.first().measure(Constraints.fixed(layoutWidth, layoutHeight))
|
}.first().measure(Constraints.fixed(layoutWidth, layoutHeight))
|
||||||
|
|
||||||
@@ -447,18 +455,21 @@ private class BottomInsetTopBarShape(private val cornerRadius: Dp) : Shape {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [HazeStyle] for chat chrome using only [androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainer]
|
* [HazeBlurStyle] for chat chrome using only [androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainer]
|
||||||
* (24dp blur + luminance-scaled tint, slightly denser than old “thin” defaults), without [dev.chrisbanes.haze.materials.HazeMaterials].
|
* (24dp blur + luminance-scaled tint, slightly denser than old “thin” defaults), without
|
||||||
|
* [dev.chrisbanes.haze.blur.materials.HazeMaterials].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberChatSurfaceContainerHazeStyle(): HazeStyle {
|
fun rememberChatSurfaceContainerHazeStyle(): HazeBlurStyle {
|
||||||
val surface = MaterialTheme.colorScheme.surfaceContainer
|
val surface = MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
|
||||||
return remember(surface) {
|
return remember(surface) {
|
||||||
HazeStyle(
|
HazeBlurStyle(
|
||||||
blurRadius = 24.dp,
|
blurRadius = 24.dp,
|
||||||
backgroundColor = surface,
|
backgroundColor = surface,
|
||||||
tint = HazeTint(surface.copy(alpha = if (surface.luminance() >= 0.5f) 0.74f else 0.79f)),
|
colorEffect = HazeColorEffect.tint(
|
||||||
|
surface.copy(alpha = if (surface.luminance() >= 0.5f) 0.74f else 0.79f),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -534,7 +534,7 @@ fun MessageItem(
|
|||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier.weight(1f, fill = false)
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
) {
|
) {
|
||||||
val maxBubbleWidth = maxWidth * 0.7f
|
val maxBubbleWidth = minOf(maxWidth * 0.7f, 420.dp)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
|
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ data class SelectedAttachment(
|
|||||||
/**
|
/**
|
||||||
* Platform-specific image picker. Returns a launch function.
|
* Platform-specific image picker. Returns a launch function.
|
||||||
* On Android: PickVisualMedia (images only, multiple).
|
* On Android: PickVisualMedia (images only, multiple).
|
||||||
* On iOS: Placeholder (no-op until Phase 4).
|
* On iOS: PHPickerViewController (images only, multiple).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
expect fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit
|
expect fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit
|
||||||
@@ -21,7 +21,7 @@ expect fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit
|
|||||||
/**
|
/**
|
||||||
* Platform-specific file picker. Returns a launch function.
|
* Platform-specific file picker. Returns a launch function.
|
||||||
* On Android: OpenDocument/OpenMultipleDocuments (SAF).
|
* On Android: OpenDocument/OpenMultipleDocuments (SAF).
|
||||||
* On iOS: Placeholder (no-op until Phase 4).
|
* On iOS: UIDocumentPickerViewController (multiple, asCopy).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
expect fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit
|
expect fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit
|
||||||
|
|||||||
@@ -83,12 +83,12 @@ import androidx.compose.ui.zIndex
|
|||||||
import androidx.graphics.shapes.Morph
|
import androidx.graphics.shapes.Morph
|
||||||
import androidx.graphics.shapes.RoundedPolygon
|
import androidx.graphics.shapes.RoundedPolygon
|
||||||
import com.pr0gramm3r101.utils.LastAnchoredBottomArrangement
|
import com.pr0gramm3r101.utils.LastAnchoredBottomArrangement
|
||||||
import dev.chrisbanes.haze.HazeProgressive
|
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.blur.HazeProgressive
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
|
import dev.chrisbanes.haze.blur.materials.HazeMaterials
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -333,7 +333,6 @@ fun MorphedExpressiveStepButton(
|
|||||||
@OptIn(
|
@OptIn(
|
||||||
ExperimentalAnimationApi::class,
|
ExperimentalAnimationApi::class,
|
||||||
ExperimentalMaterial3Api::class,
|
ExperimentalMaterial3Api::class,
|
||||||
ExperimentalHazeMaterialsApi::class,
|
|
||||||
)
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun ExpressiveStepFlowScaffold(
|
fun ExpressiveStepFlowScaffold(
|
||||||
@@ -549,6 +548,7 @@ fun ExpressiveStepFlowScaffold(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HazeTopBar(hazeState: HazeState) {
|
fun HazeTopBar(hazeState: HazeState) {
|
||||||
|
val topBarHazeStyle = HazeMaterials.thin()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {},
|
title = {},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -566,11 +566,14 @@ fun ExpressiveStepFlowScaffold(
|
|||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
scrolledContainerColor = Color.Transparent,
|
scrolledContainerColor = Color.Transparent,
|
||||||
),
|
),
|
||||||
modifier = Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
modifier = Modifier.hazeEffect(state = hazeState) {
|
||||||
progressive = HazeProgressive.verticalGradient(
|
blurEffect {
|
||||||
startIntensity = 1f,
|
style = topBarHazeStyle
|
||||||
endIntensity = 0f,
|
progressive = HazeProgressive.verticalGradient(
|
||||||
)
|
startIntensity = 1f,
|
||||||
|
endIntensity = 0f,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,30 +12,37 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.chrisbanes.haze.HazeProgressive
|
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.HazeStyle
|
import dev.chrisbanes.haze.blur.HazeBlurStyle
|
||||||
|
import dev.chrisbanes.haze.blur.HazeProgressive
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
|
import dev.chrisbanes.haze.blur.materials.HazeMaterials
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
|
||||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HazeBottomBar(
|
fun HazeBottomBar(
|
||||||
hazeState: HazeState,
|
hazeState: HazeState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
hazeStyle: HazeStyle? = null,
|
hazeStyle: HazeBlurStyle? = null,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val resolvedStyle = hazeStyle ?: HazeMaterials.thin()
|
||||||
val effectModifier = if (hazeStyle != null) {
|
val effectModifier = if (hazeStyle != null) {
|
||||||
Modifier.hazeEffect(state = hazeState, style = hazeStyle)
|
Modifier.hazeEffect(state = hazeState) {
|
||||||
|
blurEffect {
|
||||||
|
style = resolvedStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
Modifier.hazeEffect(state = hazeState) {
|
||||||
progressive = HazeProgressive.verticalGradient(
|
blurEffect {
|
||||||
startIntensity = 0f,
|
style = resolvedStyle
|
||||||
endIntensity = 1f,
|
progressive = HazeProgressive.verticalGradient(
|
||||||
)
|
startIntensity = 0f,
|
||||||
|
endIntensity = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
@@ -57,14 +64,13 @@ fun HazeBottomBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HazeActionButton(
|
fun HazeActionButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
hazeState: HazeState,
|
hazeState: HazeState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
innerModifier: Modifier = Modifier,
|
innerModifier: Modifier = Modifier,
|
||||||
hazeStyle: HazeStyle? = null,
|
hazeStyle: HazeBlurStyle? = null,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
loading: Boolean = false,
|
loading: Boolean = false,
|
||||||
interactionSource: MutableInteractionSource? = null,
|
interactionSource: MutableInteractionSource? = null,
|
||||||
@@ -80,4 +86,4 @@ fun HazeActionButton(
|
|||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
@@ -44,14 +46,18 @@ import androidx.compose.ui.layout.onSizeChanged
|
|||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
|
import com.pr0gramm3r101.utils.NavigationItem
|
||||||
|
import com.pr0gramm3r101.utils.SimpleNavigationRail
|
||||||
|
import com.pr0gramm3r101.utils.WindowWidthSizeClass
|
||||||
|
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
|
||||||
|
import com.pr0gramm3r101.utils.widthSizeClass
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.HazeStyle
|
import dev.chrisbanes.haze.blur.HazeBlurStyle
|
||||||
import dev.chrisbanes.haze.HazeTint
|
import dev.chrisbanes.haze.blur.HazeColorEffect
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
@@ -61,6 +67,7 @@ import ru.fromchat.contacts
|
|||||||
import ru.fromchat.profile
|
import ru.fromchat.profile
|
||||||
import ru.fromchat.settings
|
import ru.fromchat.settings
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
|
||||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
import ru.fromchat.ui.components.FromChatSnackbarHost
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController
|
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController
|
||||||
@@ -75,12 +82,17 @@ private const val PAGE_SETTINGS = 2
|
|||||||
private const val PAGE_PROFILE = 3
|
private const val PAGE_PROFILE = 3
|
||||||
private const val PAGE_COUNT = 4
|
private const val PAGE_COUNT = 4
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
/**
|
||||||
|
* @param listPaneOnly When true, renders only the chats list (no nav rail/bar) for use as the
|
||||||
|
* list side of an expanded list–detail conversation layout.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
sharedTransitionScope: SharedTransitionScope? = null,
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
snackbarHostState: SnackbarHostState? = null,
|
snackbarHostState: SnackbarHostState? = null,
|
||||||
|
listPaneOnly: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
|
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
@@ -90,11 +102,14 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val navBarHazeState = rememberHazeState(blurEnabled = true)
|
val navBarHazeState = rememberHazeState()
|
||||||
val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||||
val contextMenuHazeState = rememberHazeState(blurEnabled = true)
|
val contextMenuHazeState = rememberHazeState()
|
||||||
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
|
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
|
||||||
|
|
||||||
|
val useNavigationRail = !listPaneOnly &&
|
||||||
|
currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
|
||||||
|
|
||||||
val selectedPage = pagerState.currentPage
|
val selectedPage = pagerState.currentPage
|
||||||
val isChatsPage = selectedPage == PAGE_CHATS
|
val isChatsPage = selectedPage == PAGE_CHATS
|
||||||
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
|
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
|
||||||
@@ -102,46 +117,83 @@ fun MainScreen(
|
|||||||
val statusBarTopDp = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
val statusBarTopDp = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||||
var bottomChromeHeightDp by remember { mutableStateOf(0.dp) }
|
var bottomChromeHeightDp by remember { mutableStateOf(0.dp) }
|
||||||
|
|
||||||
val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp) {
|
val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp, useNavigationRail, listPaneOnly) {
|
||||||
MainChromeInsets(
|
MainChromeInsets(
|
||||||
top = statusBarTopDp,
|
top = statusBarTopDp,
|
||||||
bottom = bottomChromeHeightDp,
|
bottom = if (useNavigationRail || listPaneOnly) 0.dp else bottomChromeHeightDp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
val chatsLabel = stringResource(Res.string.chats)
|
||||||
Box(
|
val contactsLabel = stringResource(Res.string.contacts)
|
||||||
modifier = Modifier
|
val settingsLabel = stringResource(Res.string.settings)
|
||||||
.fillMaxSize()
|
val profileLabel = stringResource(Res.string.profile)
|
||||||
.hazeSource(contextMenuHazeState),
|
|
||||||
) {
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
Scaffold(
|
Row(Modifier.fillMaxSize()) {
|
||||||
snackbarHost = {
|
if (useNavigationRail) {
|
||||||
FromChatSnackbarHost(
|
SimpleNavigationRail(
|
||||||
hostState = effectiveSnackbarHostState,
|
selectedItem = selectedPage,
|
||||||
modifier = Modifier.padding(bottom = mainChromeInsets.bottom),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
)
|
NavigationItem(
|
||||||
},
|
name = chatsLabel,
|
||||||
containerColor = Color.Transparent,
|
selectedIcon = Icons.AutoMirrored.Filled.Chat,
|
||||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
onClick = {
|
||||||
|
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
|
||||||
|
},
|
||||||
|
),
|
||||||
|
NavigationItem(
|
||||||
|
name = contactsLabel,
|
||||||
|
selectedIcon = Icons.Filled.Contacts,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
|
||||||
|
},
|
||||||
|
),
|
||||||
|
NavigationItem(
|
||||||
|
name = settingsLabel,
|
||||||
|
selectedIcon = Icons.Filled.Settings,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
|
||||||
|
},
|
||||||
|
),
|
||||||
|
NavigationItem(
|
||||||
|
name = profileLabel,
|
||||||
|
selectedIcon = Icons.Filled.Person,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.weight(1f)
|
||||||
.imePadding(),
|
.fillMaxHeight()
|
||||||
|
.hazeSource(contextMenuHazeState),
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalMainChromeInsets provides mainChromeInsets) {
|
Scaffold(
|
||||||
Box(
|
snackbarHost = {
|
||||||
modifier = Modifier
|
FromChatSnackbarHost(
|
||||||
.fillMaxSize()
|
hostState = effectiveSnackbarHostState,
|
||||||
.hazeSource(navBarHazeState),
|
modifier = Modifier.padding(bottom = mainChromeInsets.bottom),
|
||||||
) {
|
)
|
||||||
HorizontalPager(
|
},
|
||||||
state = pagerState,
|
containerColor = Color.Transparent,
|
||||||
modifier = Modifier.fillMaxSize(),
|
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||||
beyondViewportPageCount = 1,
|
modifier = Modifier
|
||||||
) { page ->
|
.fillMaxSize()
|
||||||
when (page) {
|
.imePadding(),
|
||||||
PAGE_CHATS -> ChatsTab(
|
) {
|
||||||
isVisible = isChatsPage,
|
CompositionLocalProvider(LocalMainChromeInsets provides mainChromeInsets) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.hazeSource(navBarHazeState),
|
||||||
|
) {
|
||||||
|
if (listPaneOnly) {
|
||||||
|
ChatsTab(
|
||||||
|
isVisible = true,
|
||||||
onOpenSearch = {
|
onOpenSearch = {
|
||||||
navController.navigate("search/conversations")
|
navController.navigate("search/conversations")
|
||||||
},
|
},
|
||||||
@@ -149,81 +201,118 @@ fun MainScreen(
|
|||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
)
|
)
|
||||||
PAGE_CONTACTS -> ContactsTab()
|
} else {
|
||||||
PAGE_SETTINGS -> SettingsTab()
|
HorizontalPager(
|
||||||
PAGE_PROFILE -> {
|
state = pagerState,
|
||||||
ProfileScreen(
|
modifier = Modifier.fillMaxSize(),
|
||||||
userId = ApiClient.user?.id,
|
beyondViewportPageCount = 1,
|
||||||
onBack = {},
|
) { page ->
|
||||||
onChat = { _ -> },
|
when (page) {
|
||||||
modifier = Modifier
|
PAGE_CHATS -> ChatsTab(
|
||||||
.fillMaxSize()
|
isVisible = isChatsPage,
|
||||||
.mainPagerBottomInset(),
|
onOpenSearch = {
|
||||||
onOpenSettings = {
|
navController.navigate("search/conversations")
|
||||||
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
|
},
|
||||||
},
|
chatContextMenuOverlay = chatContextMenuOverlay,
|
||||||
)
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
|
)
|
||||||
|
PAGE_CONTACTS -> ContactsTab()
|
||||||
|
PAGE_SETTINGS -> SettingsTab()
|
||||||
|
PAGE_PROFILE -> {
|
||||||
|
ProfileScreen(
|
||||||
|
userId = ApiClient.user?.id,
|
||||||
|
onBack = {},
|
||||||
|
onChat = { _ -> },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.mainPagerBottomInset(),
|
||||||
|
onOpenSettings = {
|
||||||
|
scope.launch {
|
||||||
|
pagerState.animateScrollToPage(PAGE_SETTINGS)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else -> Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
if (!useNavigationRail && !listPaneOnly) {
|
||||||
modifier = Modifier
|
Column(
|
||||||
.align(Alignment.BottomCenter)
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.align(Alignment.BottomCenter)
|
||||||
.zIndex(1f)
|
.fillMaxWidth()
|
||||||
.imePadding()
|
.zIndex(1f)
|
||||||
.onSizeChanged { size ->
|
.imePadding()
|
||||||
val measured = with(density) { size.height.toDp() }
|
.onSizeChanged { size ->
|
||||||
if (measured != bottomChromeHeightDp) {
|
val measured = with(density) { size.height.toDp() }
|
||||||
bottomChromeHeightDp = measured
|
if (measured != bottomChromeHeightDp) {
|
||||||
|
bottomChromeHeightDp = measured
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.hazeEffect(state = navBarHazeState) {
|
||||||
|
blurEffect {
|
||||||
|
style = navBarHazeStyle
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
NavigationBar(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
|
||||||
|
) {
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedPage == PAGE_CHATS,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
|
||||||
|
},
|
||||||
|
label = { Text(chatsLabel) },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.Chat,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedPage == PAGE_CONTACTS,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
|
||||||
|
},
|
||||||
|
label = { Text(contactsLabel) },
|
||||||
|
icon = {
|
||||||
|
Icon(Icons.Filled.Contacts, contentDescription = null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedPage == PAGE_SETTINGS,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
|
||||||
|
},
|
||||||
|
label = { Text(settingsLabel) },
|
||||||
|
icon = {
|
||||||
|
Icon(Icons.Filled.Settings, contentDescription = null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedPage == PAGE_PROFILE,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
|
||||||
|
},
|
||||||
|
label = { Text(profileLabel) },
|
||||||
|
icon = {
|
||||||
|
Icon(Icons.Filled.Person, contentDescription = null)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
|
||||||
.hazeEffect(state = navBarHazeState, style = navBarHazeStyle),
|
|
||||||
) {
|
|
||||||
NavigationBar(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
tonalElevation = 0.dp,
|
|
||||||
windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
|
|
||||||
) {
|
|
||||||
NavigationBarItem(
|
|
||||||
selected = selectedPage == PAGE_CHATS,
|
|
||||||
onClick = {
|
|
||||||
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(Res.string.chats)) },
|
|
||||||
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) },
|
|
||||||
)
|
|
||||||
NavigationBarItem(
|
|
||||||
selected = selectedPage == PAGE_CONTACTS,
|
|
||||||
onClick = {
|
|
||||||
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(Res.string.contacts)) },
|
|
||||||
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) },
|
|
||||||
)
|
|
||||||
NavigationBarItem(
|
|
||||||
selected = selectedPage == PAGE_SETTINGS,
|
|
||||||
onClick = {
|
|
||||||
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(Res.string.settings)) },
|
|
||||||
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
|
||||||
)
|
|
||||||
NavigationBarItem(
|
|
||||||
selected = selectedPage == PAGE_PROFILE,
|
|
||||||
onClick = {
|
|
||||||
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(Res.string.profile)) },
|
|
||||||
icon = { Icon(Icons.Filled.Person, contentDescription = null) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,7 +338,6 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatContextMenuBlurLayer(
|
private fun ChatContextMenuBlurLayer(
|
||||||
hazeState: HazeState,
|
hazeState: HazeState,
|
||||||
@@ -260,15 +348,16 @@ private fun ChatContextMenuBlurLayer(
|
|||||||
if (blurRadius <= 0.dp) return
|
if (blurRadius <= 0.dp) return
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier.hazeEffect(
|
modifier = modifier.hazeEffect(state = hazeState) {
|
||||||
state = hazeState,
|
blurEffect {
|
||||||
style = HazeStyle(
|
style = HazeBlurStyle(
|
||||||
blurRadius = blurRadius,
|
blurRadius = blurRadius,
|
||||||
tints = emptyList(),
|
colorEffects = emptyList(),
|
||||||
backgroundColor = Color.Transparent,
|
backgroundColor = Color.Transparent,
|
||||||
noiseFactor = 0f,
|
noiseFactor = 0f,
|
||||||
fallbackTint = HazeTint(Color.Transparent),
|
fallbackColorEffect = HazeColorEffect.tint(Color.Transparent),
|
||||||
),
|
)
|
||||||
),
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,10 +59,10 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
|
import dev.chrisbanes.haze.blur.materials.HazeMaterials
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -340,20 +340,23 @@ private fun chatsTopAppBarColors(blurReveal: Float) = TopAppBarDefaults.topAppBa
|
|||||||
scrolledContainerColor = Color.Transparent,
|
scrolledContainerColor = Color.Transparent,
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatsTopBarHazeBackdrop(
|
private fun ChatsTopBarHazeBackdrop(
|
||||||
hazeState: HazeState,
|
hazeState: HazeState,
|
||||||
blurReveal: Float,
|
blurReveal: Float,
|
||||||
|
blurEnabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val topBarHazeStyle = HazeMaterials.thin()
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.graphicsLayer { alpha = blurReveal.coerceIn(0f, 1f) }
|
.graphicsLayer { alpha = blurReveal.coerceIn(0f, 1f) }
|
||||||
.hazeEffect(
|
.hazeEffect(state = hazeState) {
|
||||||
state = hazeState,
|
blurEffect {
|
||||||
style = HazeMaterials.thin(),
|
this.blurEnabled = blurEnabled
|
||||||
),
|
style = topBarHazeStyle
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,7 +521,7 @@ private fun ChatsSelectionTopBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatsTab(
|
fun ChatsTab(
|
||||||
isVisible: Boolean = true,
|
isVisible: Boolean = true,
|
||||||
@@ -573,7 +576,7 @@ fun ChatsTab(
|
|||||||
val publicChatProfile = publicChatProfileFromFlow ?: publicChatProfileFromDisk
|
val publicChatProfile = publicChatProfileFromFlow ?: publicChatProfileFromDisk
|
||||||
val searchBarHint = stringResource(Res.string.search_title)
|
val searchBarHint = stringResource(Res.string.search_title)
|
||||||
val tabListState = rememberLazyListState()
|
val tabListState = rememberLazyListState()
|
||||||
val topChromeHazeState = rememberHazeState(blurEnabled = isVisible)
|
val topChromeHazeState = rememberHazeState()
|
||||||
val statusMap by UserStatusStore.status.collectAsState()
|
val statusMap by UserStatusStore.status.collectAsState()
|
||||||
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
|
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
|
||||||
val statusSubscriptionScope = rememberCoroutineScope()
|
val statusSubscriptionScope = rememberCoroutineScope()
|
||||||
@@ -1032,6 +1035,7 @@ fun ChatsTab(
|
|||||||
ChatsTopBarHazeBackdrop(
|
ChatsTopBarHazeBackdrop(
|
||||||
hazeState = topChromeHazeState,
|
hazeState = topChromeHazeState,
|
||||||
blurReveal = topBarBlurReveal,
|
blurReveal = topBarBlurReveal,
|
||||||
|
blurEnabled = isVisible,
|
||||||
modifier = Modifier.matchParentSize(),
|
modifier = Modifier.matchParentSize(),
|
||||||
)
|
)
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
|||||||
@@ -67,11 +67,11 @@ import coil3.compose.AsyncImage
|
|||||||
import com.pr0gramm3r101.components.Category
|
import com.pr0gramm3r101.components.Category
|
||||||
import com.pr0gramm3r101.components.ListItem
|
import com.pr0gramm3r101.components.ListItem
|
||||||
import com.pr0gramm3r101.utils.currentDeviceInfo
|
import com.pr0gramm3r101.utils.currentDeviceInfo
|
||||||
import dev.chrisbanes.haze.HazeProgressive
|
import dev.chrisbanes.haze.blur.HazeProgressive
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
|
import dev.chrisbanes.haze.blur.materials.HazeMaterials
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import io.ktor.client.network.sockets.ConnectTimeoutException
|
import io.ktor.client.network.sockets.ConnectTimeoutException
|
||||||
import io.ktor.client.network.sockets.SocketTimeoutException
|
import io.ktor.client.network.sockets.SocketTimeoutException
|
||||||
@@ -363,13 +363,13 @@ private fun DeviceSessionDetailBottomSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class,
|
@OptIn(ExperimentalMaterial3Api::class,
|
||||||
ExperimentalMaterial3ExpressiveApi::class
|
ExperimentalMaterial3ExpressiveApi::class
|
||||||
)
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun DevicesScreen(onBack: () -> Unit) {
|
fun DevicesScreen(onBack: () -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val hazeState = rememberHazeState(blurEnabled = true)
|
val hazeState = rememberHazeState()
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val initialCache = remember { Settings.readDeviceSessionsCache() }
|
val initialCache = remember { Settings.readDeviceSessionsCache() }
|
||||||
@@ -430,6 +430,7 @@ fun DevicesScreen(onBack: () -> Unit) {
|
|||||||
contentWindowInsets = WindowInsets.navigationBars,
|
contentWindowInsets = WindowInsets.navigationBars,
|
||||||
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
|
val topBarHazeStyle = HazeMaterials.thin()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {},
|
title = {},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -441,14 +442,14 @@ fun DevicesScreen(onBack: () -> Unit) {
|
|||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
scrolledContainerColor = Color.Transparent
|
scrolledContainerColor = Color.Transparent
|
||||||
),
|
),
|
||||||
modifier = Modifier.hazeEffect(
|
modifier = Modifier.hazeEffect(state = hazeState) {
|
||||||
state = hazeState,
|
blurEffect {
|
||||||
style = HazeMaterials.thin()
|
style = topBarHazeStyle
|
||||||
) {
|
progressive = HazeProgressive.verticalGradient(
|
||||||
progressive = HazeProgressive.verticalGradient(
|
startIntensity = 1f,
|
||||||
startIntensity = 1f,
|
endIntensity = 0f,
|
||||||
endIntensity = 0f,
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -707,6 +707,7 @@ private fun formatLogFileSize(sizeBytes: Long): String {
|
|||||||
if (sizeBytes < 1024 * 1024) {
|
if (sizeBytes < 1024 * 1024) {
|
||||||
return stringResource(Res.string.logs_file_size_kb, kb)
|
return stringResource(Res.string.logs_file_size_kb, kb)
|
||||||
}
|
}
|
||||||
val megabytes = "%.1f".format(sizeBytes / (1024f * 1024f))
|
val mb = sizeBytes / (1024.0 * 1024.0)
|
||||||
|
val megabytes = ((kotlin.math.round(mb * 10.0) / 10.0)).toString()
|
||||||
return stringResource(Res.string.logs_file_size_mb, megabytes)
|
return stringResource(Res.string.logs_file_size_mb, megabytes)
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-37
@@ -44,6 +44,7 @@ import ru.fromchat.settings_notifications_permission_required
|
|||||||
import ru.fromchat.settings_notifications_title
|
import ru.fromchat.settings_notifications_title
|
||||||
import ru.fromchat.settings_push_notifications
|
import ru.fromchat.settings_push_notifications
|
||||||
import ru.fromchat.settings_push_notifications_d
|
import ru.fromchat.settings_push_notifications_d
|
||||||
|
import ru.fromchat.settings_push_notifications_unavailable
|
||||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
import ru.fromchat.ui.components.FromChatSnackbarHost
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
|
|
||||||
@@ -84,48 +85,58 @@ fun NotificationsScreen(onBack: () -> Unit) {
|
|||||||
Category(
|
Category(
|
||||||
modifier = Modifier.padding(top = 16.dp)
|
modifier = Modifier.padding(top = 16.dp)
|
||||||
) {
|
) {
|
||||||
SwitchListItem(
|
if (arePushNotificationsSupported()) {
|
||||||
headline = stringResource(Res.string.settings_push_notifications),
|
SwitchListItem(
|
||||||
supportingText = stringResource(Res.string.settings_push_notifications_d),
|
headline = stringResource(Res.string.settings_push_notifications),
|
||||||
leadingContent = {
|
supportingText = stringResource(Res.string.settings_push_notifications_d),
|
||||||
Icon(Icons.Filled.Notifications, null)
|
leadingContent = {
|
||||||
},
|
Icon(Icons.Filled.Notifications, null)
|
||||||
checked = notificationsEnabled,
|
},
|
||||||
onCheckedChange = { enabled ->
|
checked = notificationsEnabled,
|
||||||
coroutineScope.launch {
|
onCheckedChange = { enabled ->
|
||||||
if (enabled) {
|
coroutineScope.launch {
|
||||||
if (!areAppNotificationsEnabled()) {
|
if (enabled) {
|
||||||
if (!openAppNotificationSettings()) {
|
if (!areAppNotificationsEnabled()) {
|
||||||
snackbarHostState.showSnackbar(message = unexpectedErrorText)
|
if (!openAppNotificationSettings()) {
|
||||||
} else {
|
snackbarHostState.showSnackbar(message = unexpectedErrorText)
|
||||||
snackbarHostState.showSnackbar(message = notificationsPermissionText)
|
} else {
|
||||||
|
snackbarHostState.showSnackbar(message = notificationsPermissionText)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val registered = ensureFcmTokenRegistered()
|
val registered = ensureFcmTokenRegistered()
|
||||||
if (registered) {
|
if (registered) {
|
||||||
notificationsEnabled = true
|
notificationsEnabled = true
|
||||||
|
} else {
|
||||||
|
snackbarHostState.showSnackbar(message = unexpectedErrorText)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
snackbarHostState.showSnackbar(message = unexpectedErrorText)
|
unregisterFcmTokenFromServer()
|
||||||
|
notificationsEnabled = false
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
unregisterFcmTokenFromServer()
|
|
||||||
notificationsEnabled = false
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
divider = true
|
||||||
divider = true
|
)
|
||||||
)
|
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.settings_notification_settings),
|
headline = stringResource(Res.string.settings_notification_settings),
|
||||||
supportingText = stringResource(Res.string.settings_notification_settings_d),
|
supportingText = stringResource(Res.string.settings_notification_settings_d),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Icon(Icons.Filled.Settings, null)
|
Icon(Icons.Filled.Settings, null)
|
||||||
},
|
},
|
||||||
onClick = { openAppNotificationSettings() }
|
onClick = { openAppNotificationSettings() }
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
ListItem(
|
||||||
|
headline = stringResource(Res.string.settings_push_notifications),
|
||||||
|
supportingText = stringResource(Res.string.settings_push_notifications_unavailable),
|
||||||
|
leadingContent = {
|
||||||
|
Icon(Icons.Filled.Notifications, null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,4 +151,7 @@ expect fun openAppNotificationSettings(): Boolean
|
|||||||
/**
|
/**
|
||||||
* Returns true when notifications are currently enabled for this app, including runtime permission.
|
* Returns true when notifications are currently enabled for this app, including runtime permission.
|
||||||
*/
|
*/
|
||||||
expect fun areAppNotificationsEnabled(): Boolean
|
expect fun areAppNotificationsEnabled(): Boolean
|
||||||
|
|
||||||
|
/** False on platforms without push delivery (e.g. iOS without APNs). */
|
||||||
|
expect fun arePushNotificationsSupported(): Boolean
|
||||||
+12
-9
@@ -67,11 +67,11 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
|
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
|
||||||
import com.pr0gramm3r101.utils.toDp
|
import com.pr0gramm3r101.utils.toDp
|
||||||
import dev.chrisbanes.haze.HazeProgressive
|
import dev.chrisbanes.haze.blur.HazeProgressive
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
|
import dev.chrisbanes.haze.blur.materials.HazeMaterials
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -129,7 +129,6 @@ private object ServerConfigLazyListIndices {
|
|||||||
ExperimentalMaterial3Api::class,
|
ExperimentalMaterial3Api::class,
|
||||||
ExperimentalMaterial3ExpressiveApi::class,
|
ExperimentalMaterial3ExpressiveApi::class,
|
||||||
ExperimentalAnimationApi::class,
|
ExperimentalAnimationApi::class,
|
||||||
ExperimentalHazeMaterialsApi::class,
|
|
||||||
)
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerConfigScreen() {
|
fun ServerConfigScreen() {
|
||||||
@@ -331,6 +330,7 @@ fun ServerConfigScreen() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
topBar = {
|
topBar = {
|
||||||
|
val topBarHazeStyle = HazeMaterials.thin()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {},
|
title = {},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -345,11 +345,14 @@ fun ServerConfigScreen() {
|
|||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
scrolledContainerColor = Color.Transparent
|
scrolledContainerColor = Color.Transparent
|
||||||
),
|
),
|
||||||
modifier = Modifier.hazeEffect(state = actionHazeState, style = HazeMaterials.thin()) {
|
modifier = Modifier.hazeEffect(state = actionHazeState) {
|
||||||
progressive = HazeProgressive.verticalGradient(
|
blurEffect {
|
||||||
startIntensity = 1f,
|
style = topBarHazeStyle
|
||||||
endIntensity = 0f,
|
progressive = HazeProgressive.verticalGradient(
|
||||||
)
|
startIntensity = 1f,
|
||||||
|
endIntensity = 0f,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,11 @@ import androidx.compose.ui.text.TextRange
|
|||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.chrisbanes.haze.HazeProgressive
|
import dev.chrisbanes.haze.blur.HazeProgressive
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
|
import dev.chrisbanes.haze.blur.materials.HazeMaterials
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.plugins.ClientRequestException
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
@@ -110,7 +110,6 @@ private object EditProfileLazyListIndices {
|
|||||||
ExperimentalMaterial3Api::class,
|
ExperimentalMaterial3Api::class,
|
||||||
ExperimentalMaterial3ExpressiveApi::class,
|
ExperimentalMaterial3ExpressiveApi::class,
|
||||||
ExperimentalFoundationApi::class,
|
ExperimentalFoundationApi::class,
|
||||||
ExperimentalHazeMaterialsApi::class,
|
|
||||||
)
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun EditProfileScreen(
|
fun EditProfileScreen(
|
||||||
@@ -340,6 +339,7 @@ fun EditProfileScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
topBar = {
|
topBar = {
|
||||||
|
val topBarHazeStyle = HazeMaterials.thin()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(Res.string.profile_edit_title)) },
|
title = { Text(stringResource(Res.string.profile_edit_title)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -367,11 +367,14 @@ fun EditProfileScreen(
|
|||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
scrolledContainerColor = Color.Transparent,
|
scrolledContainerColor = Color.Transparent,
|
||||||
),
|
),
|
||||||
modifier = Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
modifier = Modifier.hazeEffect(state = hazeState) {
|
||||||
progressive = HazeProgressive.verticalGradient(
|
blurEffect {
|
||||||
startIntensity = 1f,
|
style = topBarHazeStyle
|
||||||
endIntensity = 0f,
|
progressive = HazeProgressive.verticalGradient(
|
||||||
)
|
startIntensity = 1f,
|
||||||
|
endIntensity = 0f,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,11 +37,13 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.pr0gramm3r101.components.ListItemPosition
|
import com.pr0gramm3r101.components.ListItemPosition
|
||||||
import com.pr0gramm3r101.utils.SupportClipboardManager
|
import com.pr0gramm3r101.utils.SupportClipboardManager
|
||||||
import dev.chrisbanes.haze.HazeProgressive
|
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.blur.HazeProgressive
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
|
import dev.chrisbanes.haze.blur.materials.HazeMaterials
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||||
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
|
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
|
||||||
@@ -112,8 +114,6 @@ import com.pr0gramm3r101.components.ContextMenuPressable
|
|||||||
import com.pr0gramm3r101.components.ListItem
|
import com.pr0gramm3r101.components.ListItem
|
||||||
import com.pr0gramm3r101.components.listItemPositionInGroup
|
import com.pr0gramm3r101.components.listItemPositionInGroup
|
||||||
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
|
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
|
||||||
import dev.chrisbanes.haze.hazeSource
|
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
|
||||||
import io.ktor.client.plugins.ClientRequestException
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.awaitCancellation
|
import kotlinx.coroutines.awaitCancellation
|
||||||
@@ -2028,7 +2028,6 @@ private fun ExpressiveProfileActionButton(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProfileFloatingBackBar(
|
private fun ProfileFloatingBackBar(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
@@ -2039,16 +2038,20 @@ private fun ProfileFloatingBackBar(
|
|||||||
) {
|
) {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
|
|
||||||
|
val backBarHazeStyle = HazeMaterials.thin()
|
||||||
Box(modifier = modifier.fillMaxWidth()) {
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(blurHeight)
|
.height(blurHeight)
|
||||||
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
.hazeEffect(state = hazeState) {
|
||||||
progressive = HazeProgressive.verticalGradient(
|
blurEffect {
|
||||||
startIntensity = 1f,
|
style = backBarHazeStyle
|
||||||
endIntensity = 0f,
|
progressive = HazeProgressive.verticalGradient(
|
||||||
)
|
startIntensity = 1f,
|
||||||
|
endIntensity = 0f,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.local.send.installOutboxForegroundDrain
|
||||||
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
||||||
|
|
||||||
object IosApplicationBootstrap {
|
object IosApplicationBootstrap {
|
||||||
@@ -16,6 +17,7 @@ object IosApplicationBootstrap {
|
|||||||
fun launchOnApplicationStart() {
|
fun launchOnApplicationStart() {
|
||||||
if (started) return
|
if (started) return
|
||||||
started = true
|
started = true
|
||||||
|
installOutboxForegroundDrain()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { ApiClient.loadPersistedData() }
|
runCatching { ApiClient.loadPersistedData() }
|
||||||
runCatching { AttachmentTransferBootstrap.runColdStart() }
|
runCatching { AttachmentTransferBootstrap.runColdStart() }
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.fromchat
|
||||||
|
|
||||||
|
actual fun keepWebSocketAliveInBackground(): Boolean = false
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
package ru.fromchat.api
|
package ru.fromchat.api
|
||||||
|
|
||||||
actual suspend fun syncPushTokenAfterStartup() {
|
actual suspend fun syncPushTokenAfterStartup() {
|
||||||
// айфон г@вно поэтому этого не будет потому что не хочу платить кучу денег за App Store
|
// iOS: no APNs / FCM push token sync in this build.
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import platform.Foundation.NSFileManager
|
|||||||
import platform.Foundation.NSUserDomainMask
|
import platform.Foundation.NSUserDomainMask
|
||||||
import platform.Foundation.create
|
import platform.Foundation.create
|
||||||
import platform.Foundation.writeToFile
|
import platform.Foundation.writeToFile
|
||||||
import ru.fromchat.api.db.MessageDatabaseProvider
|
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
|
||||||
|
|
||||||
private const val GENERATION_FILE = ".generation"
|
private const val GENERATION_FILE = ".generation"
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -1,6 +1,6 @@
|
|||||||
@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||||
|
|
||||||
package ru.fromchat.api.local.cache
|
package ru.fromchat.api.local.download
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
@@ -23,8 +23,6 @@ import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext
|
|||||||
import platform.UIKit.UIImage
|
import platform.UIKit.UIImage
|
||||||
import platform.UIKit.UIImageOrientation
|
import platform.UIKit.UIImageOrientation
|
||||||
import platform.UIKit.UIImagePNGRepresentation
|
import platform.UIKit.UIImagePNGRepresentation
|
||||||
import ru.fromchat.api.local.download.decodeImageBytes
|
|
||||||
|
|
||||||
actual object PlatformDecodedBitmapCache {
|
actual object PlatformDecodedBitmapCache {
|
||||||
private val cache = mutableMapOf<String, ImageBitmap>()
|
private val cache = mutableMapOf<String, ImageBitmap>()
|
||||||
|
|
||||||
+33
-2
@@ -1,5 +1,36 @@
|
|||||||
|
@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||||
|
|
||||||
package ru.fromchat.api.local.download
|
package ru.fromchat.api.local.download
|
||||||
|
|
||||||
internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? = null
|
import kotlinx.cinterop.BetaInteropApi
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.addressOf
|
||||||
|
import kotlinx.cinterop.useContents
|
||||||
|
import kotlinx.cinterop.usePinned
|
||||||
|
import platform.Foundation.NSData
|
||||||
|
import platform.Foundation.create
|
||||||
|
import platform.UIKit.UIImage
|
||||||
|
|
||||||
internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair<Int, Int>? = null
|
internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? =
|
||||||
|
runCatching {
|
||||||
|
val image = UIImage.imageWithContentsOfFile(absolutePath) ?: return null
|
||||||
|
pixelSize(image)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair<Int, Int>? {
|
||||||
|
if (data.isEmpty()) return null
|
||||||
|
return runCatching {
|
||||||
|
val nsData = data.usePinned { pinned ->
|
||||||
|
NSData.create(bytes = pinned.addressOf(0), length = data.size.toULong())
|
||||||
|
} ?: return null
|
||||||
|
val image = UIImage.imageWithData(nsData) ?: return null
|
||||||
|
pixelSize(image)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pixelSize(image: UIImage): Pair<Int, Int>? {
|
||||||
|
val w = (image.size.useContents { width } * image.scale).toInt()
|
||||||
|
val h = (image.size.useContents { height } * image.scale).toInt()
|
||||||
|
if (w <= 0 || h <= 0) return null
|
||||||
|
return w to h
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,29 @@ package ru.fromchat.api.local.send
|
|||||||
|
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import platform.Foundation.NSNotificationCenter
|
||||||
|
import platform.UIKit.UIApplicationDidBecomeActiveNotification
|
||||||
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
|
|
||||||
/** iOS: drain outbox on enqueue and when the app returns to foreground (parity with WorkManager). */
|
private var foregroundDrainInstalled = false
|
||||||
|
|
||||||
|
/** Install once: drain outbox when the app becomes active (parity with WorkManager). */
|
||||||
|
fun installOutboxForegroundDrain() {
|
||||||
|
if (foregroundDrainInstalled) return
|
||||||
|
foregroundDrainInstalled = true
|
||||||
|
NSNotificationCenter.defaultCenter.addObserverForName(
|
||||||
|
name = UIApplicationDidBecomeActiveNotification,
|
||||||
|
`object` = null,
|
||||||
|
queue = null,
|
||||||
|
) { _ ->
|
||||||
|
val instanceId = CacheContext.activeInstanceId.value.trim()
|
||||||
|
if (instanceId.isNotEmpty()) {
|
||||||
|
scheduleOutboxProcessing(instanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** iOS: drain outbox on enqueue and when the app returns to foreground. */
|
||||||
actual fun scheduleOutboxProcessing(instanceId: String) {
|
actual fun scheduleOutboxProcessing(instanceId: String) {
|
||||||
if (instanceId.trim().isEmpty()) return
|
if (instanceId.trim().isEmpty()) return
|
||||||
MainScope().launch {
|
MainScope().launch {
|
||||||
|
|||||||
+14
-2
@@ -1,8 +1,12 @@
|
|||||||
package ru.fromchat.api.local.workers
|
package ru.fromchat.api.local.workers
|
||||||
|
|
||||||
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
import kotlinx.coroutines.launch
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
|
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
|
||||||
|
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
||||||
|
import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
||||||
|
|
||||||
actual object AttachmentUploadQueue {
|
actual object AttachmentUploadQueue {
|
||||||
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = AttachmentUploadNotifier.progressFlow
|
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = AttachmentUploadNotifier.progressFlow
|
||||||
@@ -12,6 +16,14 @@ actual object AttachmentUploadQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
actual fun cancel(jobId: String) {
|
actual fun cancel(jobId: String) {
|
||||||
// Outbox + artifact cleanup handled when user deletes pending message from UI.
|
val instanceId = CacheContext.activeInstanceId.value.trim()
|
||||||
|
if (instanceId.isEmpty()) return
|
||||||
|
MainScope().launch {
|
||||||
|
val row = MessageDatabaseProvider.database.messageDatabaseQueries
|
||||||
|
.selectOutboxItem(instanceId, jobId.trim())
|
||||||
|
.executeAsOneOrNull()
|
||||||
|
val conversationId = row?.conversationId ?: return@launch
|
||||||
|
OutgoingMessageCoordinator.cancelOutboundMessage(jobId, conversationId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,28 @@ package ru.fromchat.logging
|
|||||||
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
import kotlinx.cinterop.UByteVar
|
import kotlinx.cinterop.UByteVar
|
||||||
|
import kotlinx.cinterop.addressOf
|
||||||
import kotlinx.cinterop.alloc
|
import kotlinx.cinterop.alloc
|
||||||
import kotlinx.cinterop.allocArray
|
import kotlinx.cinterop.allocArray
|
||||||
import kotlinx.cinterop.convert
|
import kotlinx.cinterop.convert
|
||||||
import kotlinx.cinterop.memScoped
|
import kotlinx.cinterop.memScoped
|
||||||
import kotlinx.cinterop.ptr
|
import kotlinx.cinterop.ptr
|
||||||
import kotlinx.cinterop.reinterpret
|
import kotlinx.cinterop.reinterpret
|
||||||
import kotlinx.cinterop.sizeOf
|
|
||||||
import kotlinx.cinterop.toCValues
|
import kotlinx.cinterop.toCValues
|
||||||
|
import kotlinx.cinterop.usePinned
|
||||||
|
import kotlinx.cinterop.value
|
||||||
|
import platform.posix.memcpy
|
||||||
import platform.zlib.Z_DEFAULT_COMPRESSION
|
import platform.zlib.Z_DEFAULT_COMPRESSION
|
||||||
import platform.zlib.compress2
|
import platform.zlib.compress2
|
||||||
|
import platform.zlib.uLongVar
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
internal actual fun gzipCompress(input: ByteArray): ByteArray {
|
internal actual fun gzipCompress(input: ByteArray): ByteArray {
|
||||||
if (input.isEmpty()) {
|
if (input.isEmpty()) {
|
||||||
return byteArrayOf(0x1f, 0x8b.toByte(), 0x08, 0x00, 0, 0, 0, 0, 0, 0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
return byteArrayOf(
|
||||||
|
0x1f, 0x8b.toByte(), 0x08, 0x00, 0, 0, 0, 0, 0, 0x03,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val header = byteArrayOf(
|
val header = byteArrayOf(
|
||||||
@@ -50,34 +57,42 @@ internal actual fun gzipCompress(input: ByteArray): ByteArray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun deflateRaw(input: ByteArray): ByteArray = memScoped {
|
private fun deflateRaw(input: ByteArray): ByteArray {
|
||||||
if (input.isEmpty()) return@memScoped ByteArray(0)
|
if (input.isEmpty()) return ByteArray(0)
|
||||||
|
|
||||||
var capacity = (input.size + (input.size / 10) + 12).coerceAtLeast(64)
|
return memScoped {
|
||||||
while (true) {
|
var capacity = (input.size + (input.size / 10) + 12).coerceAtLeast(64)
|
||||||
val output = allocArray<UByteVar>(capacity)
|
while (true) {
|
||||||
val source = input.toUByteArray().toCValues()
|
val output = allocArray<UByteVar>(capacity)
|
||||||
val sourceLength = input.size.convert<platform.zlib.uLong>()
|
val source = input.toUByteArray().toCValues()
|
||||||
val destLength = alloc<platform.zlib.uLongVar>()
|
val sourceLength = input.size.convert<platform.zlib.uLong>()
|
||||||
destLength.value = capacity.convert()
|
val destLength = alloc<uLongVar>()
|
||||||
|
destLength.value = capacity.convert()
|
||||||
|
|
||||||
val status = compress2(
|
val status = compress2(
|
||||||
output,
|
output,
|
||||||
destLength.ptr,
|
destLength.ptr,
|
||||||
source.ptr.reinterpret(),
|
source.ptr.reinterpret(),
|
||||||
sourceLength,
|
sourceLength,
|
||||||
Z_DEFAULT_COMPRESSION,
|
Z_DEFAULT_COMPRESSION,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (status == 0) {
|
if (status == 0) {
|
||||||
val size = destLength.value.toInt()
|
val size = destLength.value.toInt()
|
||||||
return@memScoped ByteArray(size) { index -> output[index].toByte() }
|
val result = ByteArray(size)
|
||||||
}
|
result.usePinned { pinned ->
|
||||||
|
memcpy(pinned.addressOf(0), output, size.convert())
|
||||||
capacity *= 2
|
}
|
||||||
if (capacity > input.size * 20) {
|
return@memScoped result
|
||||||
return@memScoped input
|
}
|
||||||
|
|
||||||
|
capacity *= 2
|
||||||
|
if (capacity > input.size * 20) {
|
||||||
|
return@memScoped input
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@Suppress("UNREACHABLE_CODE")
|
||||||
|
ByteArray(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,28 +6,30 @@ import kotlinx.cinterop.addressOf
|
|||||||
import kotlinx.cinterop.alloc
|
import kotlinx.cinterop.alloc
|
||||||
import kotlinx.cinterop.allocArray
|
import kotlinx.cinterop.allocArray
|
||||||
import kotlinx.cinterop.convert
|
import kotlinx.cinterop.convert
|
||||||
|
import kotlinx.cinterop.get
|
||||||
import kotlinx.cinterop.memScoped
|
import kotlinx.cinterop.memScoped
|
||||||
import kotlinx.cinterop.ptr
|
import kotlinx.cinterop.ptr
|
||||||
import kotlinx.cinterop.reinterpret
|
import kotlinx.cinterop.reinterpret
|
||||||
import kotlinx.cinterop.toCValues
|
import kotlinx.cinterop.toCValues
|
||||||
import kotlinx.cinterop.usePinned
|
import kotlinx.cinterop.usePinned
|
||||||
|
import kotlinx.cinterop.value
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import platform.Foundation.NSData
|
import platform.Foundation.NSData
|
||||||
import platform.Foundation.NSFileManager
|
import platform.Foundation.NSFileManager
|
||||||
import platform.Foundation.NSString
|
|
||||||
import platform.Foundation.NSUTF8StringEncoding
|
|
||||||
import platform.Foundation.create
|
import platform.Foundation.create
|
||||||
import platform.Foundation.dataWithContentsOfFile
|
import platform.Foundation.dataWithContentsOfFile
|
||||||
import platform.Foundation.writeToFile
|
import platform.Foundation.writeToFile
|
||||||
import platform.posix.memcpy
|
import platform.posix.memcpy
|
||||||
|
import platform.zlib.uLongVar
|
||||||
|
import platform.zlib.uncompress
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
internal actual object LogFileOps {
|
internal actual object LogFileOps {
|
||||||
actual fun readText(path: String): String {
|
actual fun readText(path: String): String {
|
||||||
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return ""
|
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return ""
|
||||||
return NSString.stringWithContentsOfFile(path, encoding = NSUTF8StringEncoding, error = null) as? String
|
val raw = NSData.dataWithContentsOfFile(path) ?: return ""
|
||||||
?: ""
|
return raw.toByteArray().decodeToString()
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun readBytes(path: String): ByteArray {
|
actual fun readBytes(path: String): ByteArray {
|
||||||
@@ -36,53 +38,60 @@ internal actual object LogFileOps {
|
|||||||
return raw.toByteArray()
|
return raw.toByteArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun gzipFile(sourcePath: String, destinationPath: String) = withContext(Dispatchers.Default) {
|
actual suspend fun gzipFile(sourcePath: String, destinationPath: String) {
|
||||||
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@withContext
|
withContext(Dispatchers.Default) {
|
||||||
val raw = NSData.dataWithContentsOfFile(sourcePath) ?: return@withContext
|
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@withContext
|
||||||
val bytes = raw.toByteArray()
|
val raw = NSData.dataWithContentsOfFile(sourcePath) ?: return@withContext
|
||||||
val gzipped = gzipCompress(bytes)
|
val gzipped = gzipCompress(raw.toByteArray())
|
||||||
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
|
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
|
||||||
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
|
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
|
||||||
NSData.create(bytes = gzipped, length = gzipped.size.toULong())
|
writeBytes(destinationPath, gzipped)
|
||||||
.writeToFile(destinationPath, true)
|
NSFileManager.defaultManager.removeItemAtPath(sourcePath, null)
|
||||||
NSFileManager.defaultManager.removeItemAtPath(sourcePath, null)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun readGzipText(path: String, onProgress: (Float) -> Unit): String =
|
actual suspend fun readGzipText(path: String, onProgress: (Float) -> Unit): String =
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
val bytes = gunzipToByteArray(path, onProgress)
|
gunzipToByteArray(path, onProgress).decodeToString()
|
||||||
bytes.decodeToString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun gunzipToFile(
|
actual suspend fun gunzipToFile(
|
||||||
sourcePath: String,
|
sourcePath: String,
|
||||||
destinationPath: String,
|
destinationPath: String,
|
||||||
onProgress: (Float) -> Unit,
|
onProgress: (Float) -> Unit,
|
||||||
) = withContext(Dispatchers.Default) {
|
) {
|
||||||
val bytes = gunzipToByteArray(sourcePath, onProgress)
|
withContext(Dispatchers.Default) {
|
||||||
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
|
val bytes = gunzipToByteArray(sourcePath, onProgress)
|
||||||
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
|
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
|
||||||
NSData.create(bytes = bytes, length = bytes.size.toULong())
|
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
|
||||||
.writeToFile(destinationPath, true)
|
writeBytes(destinationPath, bytes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun zipFiles(
|
actual suspend fun zipFiles(
|
||||||
entries: List<Pair<String, String>>,
|
entries: List<Pair<String, String>>,
|
||||||
destinationPath: String,
|
destinationPath: String,
|
||||||
) = withContext(Dispatchers.Default) {
|
) {
|
||||||
if (entries.isEmpty()) return@withContext
|
withContext(Dispatchers.Default) {
|
||||||
val zipEntries = entries.mapNotNull { (entryName, sourcePath) ->
|
if (entries.isEmpty()) return@withContext
|
||||||
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@mapNotNull null
|
val zipEntries = entries.mapNotNull { (entryName, sourcePath) ->
|
||||||
ZipFileEntry(entryName, readBytes(sourcePath))
|
if (!NSFileManager.defaultManager.fileExistsAtPath(sourcePath)) return@mapNotNull null
|
||||||
|
ZipFileEntry(entryName, readBytes(sourcePath))
|
||||||
|
}
|
||||||
|
val bytes = buildStoreZipArchive(zipEntries)
|
||||||
|
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
|
||||||
|
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
|
||||||
|
writeBytes(destinationPath, bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeBytes(path: String, bytes: ByteArray) {
|
||||||
|
bytes.usePinned { pinned ->
|
||||||
|
NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
|
||||||
|
?.writeToFile(path, true)
|
||||||
}
|
}
|
||||||
val bytes = buildStoreZipArchive(zipEntries)
|
|
||||||
val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
|
|
||||||
NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
|
|
||||||
NSData.create(bytes = bytes, length = bytes.size.toULong())
|
|
||||||
.writeToFile(destinationPath, true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun gunzipToByteArray(path: String, onProgress: (Float) -> Unit): ByteArray {
|
private fun gunzipToByteArray(path: String, onProgress: (Float) -> Unit): ByteArray {
|
||||||
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) {
|
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) {
|
||||||
onProgress(1f)
|
onProgress(1f)
|
||||||
@@ -103,17 +112,16 @@ internal actual object LogFileOps {
|
|||||||
return inflated
|
return inflated
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun inflateGzipPayload(deflated: ByteArray): ByteArray = memScoped {
|
private fun inflateGzipPayload(deflated: ByteArray): ByteArray = memScoped {
|
||||||
if (deflated.isEmpty()) return@memScoped ByteArray(0)
|
if (deflated.isEmpty()) return@memScoped ByteArray(0)
|
||||||
|
|
||||||
var capacity = (deflated.size * 4).coerceAtLeast(256)
|
var capacity = (deflated.size * 4).coerceAtLeast(256)
|
||||||
while (capacity <= deflated.size * 32) {
|
while (capacity <= deflated.size * 32) {
|
||||||
val output = allocArray<UByteVar>(capacity)
|
val output = allocArray<UByteVar>(capacity)
|
||||||
val destLength = alloc<platform.zlib.uLongVar>()
|
val destLength = alloc<uLongVar>()
|
||||||
destLength.value = capacity.convert<platform.zlib.uLong>()
|
destLength.value = capacity.convert()
|
||||||
val source = deflated.toUByteArray().toCValues()
|
val source = deflated.toUByteArray().toCValues()
|
||||||
val status = platform.zlib.uncompress(
|
val status = uncompress(
|
||||||
output,
|
output,
|
||||||
destLength.ptr,
|
destLength.ptr,
|
||||||
source.ptr.reinterpret(),
|
source.ptr.reinterpret(),
|
||||||
@@ -121,14 +129,17 @@ internal actual object LogFileOps {
|
|||||||
)
|
)
|
||||||
if (status == 0) {
|
if (status == 0) {
|
||||||
val size = destLength.value.toInt()
|
val size = destLength.value.toInt()
|
||||||
return@memScoped ByteArray(size) { index -> output[index].toByte() }
|
val result = ByteArray(size)
|
||||||
|
result.usePinned { pinned ->
|
||||||
|
memcpy(pinned.addressOf(0), output, size.convert())
|
||||||
|
}
|
||||||
|
return@memScoped result
|
||||||
}
|
}
|
||||||
capacity *= 2
|
capacity *= 2
|
||||||
}
|
}
|
||||||
ByteArray(0)
|
ByteArray(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun NSData.toByteArray(): ByteArray {
|
private fun NSData.toByteArray(): ByteArray {
|
||||||
val length = this.length.toInt()
|
val length = this.length.toInt()
|
||||||
if (length == 0) return ByteArray(0)
|
if (length == 0) return ByteArray(0)
|
||||||
|
|||||||
+239
-8
@@ -1,10 +1,67 @@
|
|||||||
|
@file:OptIn(ExperimentalForeignApi::class)
|
||||||
|
|
||||||
package ru.fromchat.ui.auth.captcha
|
package ru.fromchat.ui.auth.captcha
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.viewinterop.UIKitView
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.ObjCSignatureOverride
|
||||||
|
import platform.CoreGraphics.CGRectMake
|
||||||
|
import platform.Foundation.NSError
|
||||||
|
import platform.Foundation.NSURL
|
||||||
|
import platform.Foundation.NSURLRequest
|
||||||
|
import platform.UIKit.UIColor
|
||||||
|
import platform.WebKit.WKNavigation
|
||||||
|
import platform.WebKit.WKNavigationAction
|
||||||
|
import platform.WebKit.WKNavigationActionPolicy
|
||||||
|
import platform.WebKit.WKNavigationDelegateProtocol
|
||||||
|
import platform.WebKit.WKScriptMessage
|
||||||
|
import platform.WebKit.WKScriptMessageHandlerProtocol
|
||||||
|
import platform.WebKit.WKUserContentController
|
||||||
|
import platform.WebKit.WKUserScript
|
||||||
|
import platform.WebKit.WKUserScriptInjectionTime
|
||||||
|
import platform.WebKit.WKWebView
|
||||||
|
import platform.WebKit.WKWebViewConfiguration
|
||||||
|
import platform.darwin.NSObject
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
|
|
||||||
|
private const val SMARTCAPTCHA_WEBVIEW_BASE = "https://smartcaptcha.cloud.yandex.ru/webview"
|
||||||
|
private const val NATIVE_CLIENT_HANDLER = "NativeClient"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injected at document start so [window.NativeClient] matches Android's JavascriptInterface
|
||||||
|
* before SmartCaptcha scripts run (including iframes).
|
||||||
|
*/
|
||||||
|
private const val NATIVE_CLIENT_BRIDGE_JS = """
|
||||||
|
(function() {
|
||||||
|
if (window.NativeClient && window.NativeClient.__fromchat) return;
|
||||||
|
function post(payload) {
|
||||||
|
try {
|
||||||
|
window.webkit.messageHandlers.NativeClient.postMessage(payload);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
window.NativeClient = {
|
||||||
|
__fromchat: true,
|
||||||
|
onGetToken: function(token) {
|
||||||
|
post({ method: 'onGetToken', token: String(token == null ? '' : token) });
|
||||||
|
},
|
||||||
|
onChallengeVisible: function() {
|
||||||
|
post({ method: 'onChallengeVisible' });
|
||||||
|
},
|
||||||
|
onChallengeHidden: function() {
|
||||||
|
post({ method: 'onChallengeHidden' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun SmartCaptchaWebView(
|
actual fun SmartCaptchaWebView(
|
||||||
sitekey: String,
|
sitekey: String,
|
||||||
@@ -16,12 +73,186 @@ actual fun SmartCaptchaWebView(
|
|||||||
onChallengeHidden: () -> Unit,
|
onChallengeHidden: () -> Unit,
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(sitekey) {
|
val onTokenState = rememberUpdatedState(onToken)
|
||||||
Logger.w(
|
val onReadyState = rememberUpdatedState(onReady)
|
||||||
SmartCaptchaLog.TAG,
|
val onChallengeVisibleState = rememberUpdatedState(onChallengeVisible)
|
||||||
"iOS stub: captcha unavailable sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
|
val onChallengeHiddenState = rememberUpdatedState(onChallengeHidden)
|
||||||
"languageTag=$languageTag",
|
val onErrorState = rememberUpdatedState(onError)
|
||||||
)
|
val background = MaterialTheme.colorScheme.surfaceContainer
|
||||||
onError("Captcha is not available on this platform yet.")
|
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
|
||||||
|
val captchaUrl = remember(sitekey, lang) {
|
||||||
|
"$SMARTCAPTCHA_WEBVIEW_BASE?sitekey=${sitekey.trim()}&hl=$lang"
|
||||||
}
|
}
|
||||||
|
val instanceId = remember { (100000..999999).random().toString(16) }
|
||||||
|
val heldWebView = remember { arrayOfNulls<WKWebView>(1) }
|
||||||
|
|
||||||
|
val messageHandler = remember {
|
||||||
|
object : NSObject(), WKScriptMessageHandlerProtocol {
|
||||||
|
override fun userContentController(
|
||||||
|
userContentController: WKUserContentController,
|
||||||
|
didReceiveScriptMessage: WKScriptMessage,
|
||||||
|
) {
|
||||||
|
if (didReceiveScriptMessage.name != NATIVE_CLIENT_HANDLER) return
|
||||||
|
val body = didReceiveScriptMessage.body
|
||||||
|
val map = body as? Map<*, *>
|
||||||
|
val method = map?.get("method") as? String ?: return
|
||||||
|
when (method) {
|
||||||
|
"onGetToken" -> {
|
||||||
|
val cleaned = (map["token"] as? String).orEmpty().trim()
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"JS onGetToken id=$instanceId ${SmartCaptchaLog.redactToken(cleaned)}",
|
||||||
|
)
|
||||||
|
if (cleaned.isNotEmpty()) {
|
||||||
|
onTokenState.value(cleaned)
|
||||||
|
} else {
|
||||||
|
Logger.w(SmartCaptchaLog.TAG, "JS onGetToken empty id=$instanceId")
|
||||||
|
onErrorState.value("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"onChallengeVisible" -> {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "JS onChallengeVisible id=$instanceId")
|
||||||
|
onChallengeVisibleState.value()
|
||||||
|
}
|
||||||
|
"onChallengeHidden" -> {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "JS onChallengeHidden id=$instanceId")
|
||||||
|
onChallengeHiddenState.value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val navDelegate = remember {
|
||||||
|
object : NSObject(), WKNavigationDelegateProtocol {
|
||||||
|
override fun webView(
|
||||||
|
webView: WKWebView,
|
||||||
|
decidePolicyForNavigationAction: WKNavigationAction,
|
||||||
|
decisionHandler: (WKNavigationActionPolicy) -> Unit,
|
||||||
|
) {
|
||||||
|
val url = decidePolicyForNavigationAction.request.URL?.absoluteString
|
||||||
|
val host = decidePolicyForNavigationAction.request.URL?.host?.lowercase().orEmpty()
|
||||||
|
val block = host.isNotEmpty() &&
|
||||||
|
!host.endsWith("yandex.ru") &&
|
||||||
|
!host.endsWith("yandex.com") &&
|
||||||
|
!host.endsWith("yandex.net")
|
||||||
|
Logger.d(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"decidePolicy id=$instanceId block=$block host=$host " +
|
||||||
|
"url=${SmartCaptchaLog.shortUrl(url)}",
|
||||||
|
)
|
||||||
|
decisionHandler(
|
||||||
|
if (block) {
|
||||||
|
WKNavigationActionPolicy.WKNavigationActionPolicyCancel
|
||||||
|
} else {
|
||||||
|
WKNavigationActionPolicy.WKNavigationActionPolicyAllow
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"didFinishNavigation id=$instanceId " +
|
||||||
|
"url=${SmartCaptchaLog.shortUrl(webView.URL?.absoluteString)}",
|
||||||
|
)
|
||||||
|
onReadyState.value()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun webView(
|
||||||
|
webView: WKWebView,
|
||||||
|
didFailNavigation: WKNavigation?,
|
||||||
|
withError: NSError,
|
||||||
|
) {
|
||||||
|
Logger.w(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"didFailNavigation id=$instanceId desc=${withError.localizedDescription}",
|
||||||
|
)
|
||||||
|
onErrorState.value(withError.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun webView(
|
||||||
|
webView: WKWebView,
|
||||||
|
didFailProvisionalNavigation: WKNavigation?,
|
||||||
|
withError: NSError,
|
||||||
|
) {
|
||||||
|
Logger.w(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"didFailProvisionalNavigation id=$instanceId " +
|
||||||
|
"desc=${withError.localizedDescription}",
|
||||||
|
)
|
||||||
|
onErrorState.value(withError.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(instanceId) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"WebView compose enter id=$instanceId sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
|
||||||
|
"lang=$lang languageTag=$languageTag url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
|
||||||
|
)
|
||||||
|
onDispose {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "WebView compose dispose id=$instanceId")
|
||||||
|
heldWebView[0]?.let { wv ->
|
||||||
|
wv.configuration.userContentController
|
||||||
|
.removeScriptMessageHandlerForName(NATIVE_CLIENT_HANDLER)
|
||||||
|
wv.setNavigationDelegate(null)
|
||||||
|
wv.stopLoading()
|
||||||
|
}
|
||||||
|
heldWebView[0] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UIKitView(
|
||||||
|
factory = {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "UIKitView.factory id=$instanceId")
|
||||||
|
val contentController = WKUserContentController().apply {
|
||||||
|
addScriptMessageHandler(messageHandler, name = NATIVE_CLIENT_HANDLER)
|
||||||
|
addUserScript(
|
||||||
|
WKUserScript(
|
||||||
|
source = NATIVE_CLIENT_BRIDGE_JS,
|
||||||
|
injectionTime = WKUserScriptInjectionTime.WKUserScriptInjectionTimeAtDocumentStart,
|
||||||
|
forMainFrameOnly = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val config = WKWebViewConfiguration().apply {
|
||||||
|
setUserContentController(contentController)
|
||||||
|
allowsInlineMediaPlayback = true
|
||||||
|
}
|
||||||
|
WKWebView(
|
||||||
|
frame = CGRectMake(0.0, 0.0, 0.0, 0.0),
|
||||||
|
configuration = config,
|
||||||
|
).apply {
|
||||||
|
opaque = true
|
||||||
|
backgroundColor = background.toUIColor()
|
||||||
|
scrollView.backgroundColor = background.toUIColor()
|
||||||
|
setNavigationDelegate(navDelegate)
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"loadUrl id=$instanceId url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
|
||||||
|
)
|
||||||
|
loadRequest(NSURLRequest(uRL = NSURL(string = captchaUrl)))
|
||||||
|
heldWebView[0] = this
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
update = { wv ->
|
||||||
|
wv.backgroundColor = background.toUIColor()
|
||||||
|
wv.scrollView.backgroundColor = background.toUIColor()
|
||||||
|
heldWebView[0] = wv
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Color.toUIColor(): UIColor =
|
||||||
|
UIColor(
|
||||||
|
red = red.toDouble(),
|
||||||
|
green = green.toDouble(),
|
||||||
|
blue = blue.toDouble(),
|
||||||
|
alpha = alpha.toDouble(),
|
||||||
|
)
|
||||||
|
|||||||
+381
-2
@@ -1,9 +1,108 @@
|
|||||||
|
@file:OptIn(ExperimentalForeignApi::class)
|
||||||
|
|
||||||
package ru.fromchat.ui.auth.yandex
|
package ru.fromchat.ui.auth.yandex
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.LoadingIndicator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.viewinterop.UIKitView
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.ObjCSignatureOverride
|
||||||
|
import platform.CoreGraphics.CGRectMake
|
||||||
|
import platform.Foundation.NSHTTPCookie
|
||||||
|
import platform.Foundation.NSHTTPCookieStorage
|
||||||
|
import platform.Foundation.NSURL
|
||||||
|
import platform.Foundation.NSURLRequest
|
||||||
|
import platform.UIKit.UIApplication
|
||||||
|
import platform.UIKit.UIColor
|
||||||
|
import platform.WebKit.WKNavigation
|
||||||
|
import platform.WebKit.WKNavigationAction
|
||||||
|
import platform.WebKit.WKNavigationActionPolicy
|
||||||
|
import platform.WebKit.WKNavigationDelegateProtocol
|
||||||
|
import platform.WebKit.WKUserScript
|
||||||
|
import platform.WebKit.WKUserScriptInjectionTime
|
||||||
|
import platform.WebKit.WKWebView
|
||||||
|
import platform.WebKit.WKWebViewConfiguration
|
||||||
|
import platform.darwin.NSObject
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
|
||||||
|
import ru.fromchat.auth.yandex.extractOAuthCode
|
||||||
|
|
||||||
|
private const val LOG_TAG = "YandexOAuthWV"
|
||||||
|
private const val LOADING_FADE_MS = 250
|
||||||
|
private const val LOADING_HIDE_DELAY_MS = 500L
|
||||||
|
|
||||||
|
private fun shortUrl(url: String?): String {
|
||||||
|
if (url.isNullOrBlank()) return "null"
|
||||||
|
return if (url.length <= 120) url else url.take(117) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val FORCE_COLOR_SCHEME_JS = """
|
||||||
|
(function(scheme) {
|
||||||
|
try {
|
||||||
|
var meta = document.querySelector('meta[name="color-scheme"]');
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement('meta');
|
||||||
|
meta.name = 'color-scheme';
|
||||||
|
(document.head || document.documentElement).appendChild(meta);
|
||||||
|
}
|
||||||
|
meta.content = scheme;
|
||||||
|
document.documentElement.style.colorScheme = scheme;
|
||||||
|
document.documentElement.setAttribute('data-theme', scheme);
|
||||||
|
document.documentElement.classList.toggle('theme_dark', scheme === 'dark');
|
||||||
|
document.documentElement.classList.toggle('Theme_color_dark', scheme === 'dark');
|
||||||
|
document.documentElement.classList.toggle('theme_light', scheme === 'light');
|
||||||
|
try { localStorage.setItem('color-scheme', scheme); } catch (e) {}
|
||||||
|
try { localStorage.setItem('theme', scheme); } catch (e) {}
|
||||||
|
} catch (e) {}
|
||||||
|
})
|
||||||
|
"""
|
||||||
|
|
||||||
|
private const val DISABLE_USER_SELECT_JS = """
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var id = 'fromchat-no-select';
|
||||||
|
if (document.getElementById(id)) return;
|
||||||
|
var s = document.createElement('style');
|
||||||
|
s.id = id;
|
||||||
|
s.textContent = [
|
||||||
|
'*,*::before,*::after{',
|
||||||
|
'-webkit-user-select:none!important;',
|
||||||
|
'user-select:none!important;',
|
||||||
|
'-webkit-touch-callout:none!important;',
|
||||||
|
'}',
|
||||||
|
'input,textarea,select,[contenteditable],[contenteditable="true"],',
|
||||||
|
'input *,textarea *,[contenteditable] *,[contenteditable="true"] *{',
|
||||||
|
'-webkit-user-select:text!important;',
|
||||||
|
'user-select:text!important;',
|
||||||
|
'-webkit-touch-callout:default!important;',
|
||||||
|
'}'
|
||||||
|
].join('');
|
||||||
|
(document.head || document.documentElement).appendChild(s);
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
actual fun YandexOAuthWebView(
|
actual fun YandexOAuthWebView(
|
||||||
authorizeUrl: String,
|
authorizeUrl: String,
|
||||||
@@ -17,7 +116,287 @@ actual fun YandexOAuthWebView(
|
|||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(authorizeUrl) {
|
var canGoBack by remember { mutableStateOf(false) }
|
||||||
onError("Yandex sign-in is not available on this platform yet.")
|
var pageLoading by remember { mutableStateOf(true) }
|
||||||
|
var showLoadingOverlay by remember { mutableStateOf(true) }
|
||||||
|
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
|
||||||
|
val scheme = if (darkTheme) "dark" else "light"
|
||||||
|
val onHistoryBackAvailabilityChangedState = rememberUpdatedState(onHistoryBackAvailabilityChanged)
|
||||||
|
val onPageBackgroundColorState = rememberUpdatedState(onPageBackgroundColor)
|
||||||
|
val onCodeState = rememberUpdatedState(onCode)
|
||||||
|
val onErrorState = rememberUpdatedState(onError)
|
||||||
|
val instanceId = remember { (100000..999999).random().toString(16) }
|
||||||
|
val heldWebView = remember { arrayOfNulls<WKWebView>(1) }
|
||||||
|
|
||||||
|
fun updateCanGoBack(value: Boolean) {
|
||||||
|
if (canGoBack != value) {
|
||||||
|
Logger.d(LOG_TAG, "canGoBack $canGoBack → $value id=$instanceId")
|
||||||
|
canGoBack = value
|
||||||
|
onHistoryBackAvailabilityChangedState.value(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(instanceId) {
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"compose enter id=$instanceId darkTheme=$darkTheme lang=$lang " +
|
||||||
|
"authorizeUrl=${shortUrl(authorizeUrl)}",
|
||||||
|
)
|
||||||
|
onPageBackgroundColorState.value(fallbackColor)
|
||||||
|
onDispose {
|
||||||
|
Logger.i(LOG_TAG, "compose dispose id=$instanceId")
|
||||||
|
heldWebView[0]?.let { wv ->
|
||||||
|
wv.setNavigationDelegate(null)
|
||||||
|
wv.stopLoading()
|
||||||
|
}
|
||||||
|
heldWebView[0] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(pageLoading) {
|
||||||
|
if (pageLoading) {
|
||||||
|
showLoadingOverlay = true
|
||||||
|
} else {
|
||||||
|
delay(LOADING_HIDE_DELAY_MS)
|
||||||
|
showLoadingOverlay = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val navDelegate = remember(authorizeUrl, lang, darkTheme) {
|
||||||
|
object : NSObject(), WKNavigationDelegateProtocol {
|
||||||
|
private fun handleSpecialUrl(url: String?): Boolean {
|
||||||
|
if (url.isNullOrBlank()) return false
|
||||||
|
if (url.startsWith("fromchat://", ignoreCase = true)) {
|
||||||
|
Logger.i(LOG_TAG, "intercept redirect url=${shortUrl(url)} id=$instanceId")
|
||||||
|
val code = extractOAuthCode(url)
|
||||||
|
if (code != null) {
|
||||||
|
onCodeState.value(code)
|
||||||
|
} else {
|
||||||
|
onErrorState.value("")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!isYandexAuthNavigationIos(url)) {
|
||||||
|
Logger.d(LOG_TAG, "external nav url=${shortUrl(url)} id=$instanceId")
|
||||||
|
openExternalUrl(url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPageChrome(webView: WKWebView) {
|
||||||
|
webView.evaluateJavaScript("$FORCE_COLOR_SCHEME_JS('$scheme');", completionHandler = null)
|
||||||
|
webView.evaluateJavaScript(DISABLE_USER_SELECT_JS, completionHandler = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun webView(
|
||||||
|
webView: WKWebView,
|
||||||
|
decidePolicyForNavigationAction: WKNavigationAction,
|
||||||
|
decisionHandler: (WKNavigationActionPolicy) -> Unit,
|
||||||
|
) {
|
||||||
|
val url = decidePolicyForNavigationAction.request.URL?.absoluteString
|
||||||
|
if (handleSpecialUrl(url)) {
|
||||||
|
decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyCancel)
|
||||||
|
} else {
|
||||||
|
decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyAllow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun webView(webView: WKWebView, didCommitNavigation: WKNavigation?) {
|
||||||
|
val url = webView.URL?.absoluteString
|
||||||
|
Logger.i(LOG_TAG, "didCommitNavigation id=$instanceId url=${shortUrl(url)}")
|
||||||
|
if (url != null && url.startsWith(YANDEX_OAUTH_REDIRECT_URI, ignoreCase = true)) {
|
||||||
|
handleSpecialUrl(url)
|
||||||
|
}
|
||||||
|
pageLoading = true
|
||||||
|
applyPageChrome(webView)
|
||||||
|
updateCanGoBack(webView.canGoBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) {
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"didFinishNavigation id=$instanceId url=${shortUrl(webView.URL?.absoluteString)} " +
|
||||||
|
"canGoBack=${webView.canGoBack}",
|
||||||
|
)
|
||||||
|
pageLoading = false
|
||||||
|
applyPageChrome(webView)
|
||||||
|
updateCanGoBack(webView.canGoBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun webView(
|
||||||
|
webView: WKWebView,
|
||||||
|
didFailNavigation: WKNavigation?,
|
||||||
|
withError: platform.Foundation.NSError,
|
||||||
|
) {
|
||||||
|
Logger.w(
|
||||||
|
LOG_TAG,
|
||||||
|
"didFailNavigation id=$instanceId desc=${withError.localizedDescription}",
|
||||||
|
)
|
||||||
|
pageLoading = false
|
||||||
|
updateCanGoBack(webView.canGoBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun webView(
|
||||||
|
webView: WKWebView,
|
||||||
|
didFailProvisionalNavigation: WKNavigation?,
|
||||||
|
withError: platform.Foundation.NSError,
|
||||||
|
) {
|
||||||
|
Logger.w(
|
||||||
|
LOG_TAG,
|
||||||
|
"didFailProvisionalNavigation id=$instanceId " +
|
||||||
|
"desc=${withError.localizedDescription}",
|
||||||
|
)
|
||||||
|
pageLoading = false
|
||||||
|
updateCanGoBack(webView.canGoBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(fallbackColor),
|
||||||
|
) {
|
||||||
|
UIKitView(
|
||||||
|
factory = {
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"UIKitView.factory START id=$instanceId darkTheme=$darkTheme " +
|
||||||
|
"clearCookies=$clearCookies",
|
||||||
|
)
|
||||||
|
if (clearCookies) {
|
||||||
|
clearYandexWebViewCookiesIos()
|
||||||
|
}
|
||||||
|
seedYandexThemeCookiesIos(darkTheme)
|
||||||
|
val config = WKWebViewConfiguration().apply {
|
||||||
|
allowsInlineMediaPlayback = true
|
||||||
|
userContentController.addUserScript(
|
||||||
|
WKUserScript(
|
||||||
|
source = "$FORCE_COLOR_SCHEME_JS('$scheme');$DISABLE_USER_SELECT_JS",
|
||||||
|
injectionTime =
|
||||||
|
WKUserScriptInjectionTime.WKUserScriptInjectionTimeAtDocumentStart,
|
||||||
|
forMainFrameOnly = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
WKWebView(
|
||||||
|
frame = CGRectMake(0.0, 0.0, 0.0, 0.0),
|
||||||
|
configuration = config,
|
||||||
|
).apply {
|
||||||
|
opaque = true
|
||||||
|
backgroundColor = fallbackColor.toUIColor()
|
||||||
|
scrollView.backgroundColor = fallbackColor.toUIColor()
|
||||||
|
setNavigationDelegate(navDelegate)
|
||||||
|
Logger.w(
|
||||||
|
LOG_TAG,
|
||||||
|
"UIKitView.factory loadUrl id=$instanceId url=${shortUrl(authorizeUrl)}",
|
||||||
|
)
|
||||||
|
loadRequest(NSURLRequest(uRL = NSURL(string = authorizeUrl)))
|
||||||
|
heldWebView[0] = this
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
update = { wv ->
|
||||||
|
heldWebView[0] = wv
|
||||||
|
updateCanGoBack(wv.canGoBack)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showLoadingOverlay,
|
||||||
|
enter = fadeIn(tween(0)),
|
||||||
|
exit = fadeOut(tween(LOADING_FADE_MS)),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(fallbackColor),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
LoadingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep Yandex ID / OAuth / captcha flows in the WebView; open everything else externally.
|
||||||
|
* iOS copy of Android [isYandexAuthNavigation] (androidMain-only there).
|
||||||
|
*/
|
||||||
|
private fun isYandexAuthNavigationIos(url: String): Boolean {
|
||||||
|
if (url.startsWith("fromchat://", ignoreCase = true)) return true
|
||||||
|
val nsUrl = NSURL.URLWithString(url) ?: return false
|
||||||
|
val host = nsUrl.host?.lowercase() ?: return false
|
||||||
|
val path = nsUrl.path.orEmpty().lowercase()
|
||||||
|
|
||||||
|
if (host == "yandex.ru" || host == "www.yandex.ru" || host == "ya.ru" || host == "www.ya.ru") {
|
||||||
|
return path.contains("captcha") ||
|
||||||
|
path.startsWith("/auth") ||
|
||||||
|
path.startsWith("/showcaptcha") ||
|
||||||
|
path.startsWith("/checkcaptcha")
|
||||||
|
}
|
||||||
|
|
||||||
|
return host == "oauth.yandex.com" ||
|
||||||
|
host == "oauth.yandex.ru" ||
|
||||||
|
host.endsWith(".oauth.yandex.com") ||
|
||||||
|
host.endsWith(".oauth.yandex.ru") ||
|
||||||
|
host == "passport.yandex.ru" ||
|
||||||
|
host == "passport.yandex.com" ||
|
||||||
|
host.endsWith(".passport.yandex.ru") ||
|
||||||
|
host.endsWith(".passport.yandex.com") ||
|
||||||
|
host.startsWith("auth.yandex.") ||
|
||||||
|
host.startsWith("login.yandex.") ||
|
||||||
|
host.startsWith("id.yandex.") ||
|
||||||
|
host == "sso.passport.yandex.ru" ||
|
||||||
|
host == "captcha.yandex.net" ||
|
||||||
|
host.endsWith(".captcha.yandex.net") ||
|
||||||
|
(host.contains("captcha") && host.contains("yandex"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openExternalUrl(url: String) {
|
||||||
|
val nsUrl = NSURL.URLWithString(url) ?: return
|
||||||
|
runCatching { UIApplication.sharedApplication.openURL(nsUrl) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearYandexWebViewCookiesIos() {
|
||||||
|
val storage = NSHTTPCookieStorage.sharedHTTPCookieStorage
|
||||||
|
storage.cookies?.filterIsInstance<NSHTTPCookie>()?.forEach { storage.deleteCookie(it) }
|
||||||
|
Logger.i(LOG_TAG, "cleared WebView cookies for Yandex re-auth")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seedYandexThemeCookiesIos(darkTheme: Boolean) {
|
||||||
|
val theme = if (darkTheme) "dark" else "light"
|
||||||
|
val storage = NSHTTPCookieStorage.sharedHTTPCookieStorage
|
||||||
|
val hosts = listOf(
|
||||||
|
"yandex.ru",
|
||||||
|
"yandex.com",
|
||||||
|
"passport.yandex.ru",
|
||||||
|
"passport.yandex.com",
|
||||||
|
"oauth.yandex.ru",
|
||||||
|
"oauth.yandex.com",
|
||||||
|
)
|
||||||
|
for (domain in hosts) {
|
||||||
|
for (nameValue in listOf("color_scheme=$theme", "theme=$theme", "yh=Theme=$theme")) {
|
||||||
|
val props: Map<Any?, *> = mapOf(
|
||||||
|
"Name" to nameValue.substringBefore('='),
|
||||||
|
"Value" to nameValue.substringAfter('='),
|
||||||
|
"Domain" to domain,
|
||||||
|
"Path" to "/",
|
||||||
|
)
|
||||||
|
NSHTTPCookie.cookieWithProperties(props)?.let { storage.setCookie(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Color.toUIColor(): UIColor =
|
||||||
|
UIColor(
|
||||||
|
red = red.toDouble(),
|
||||||
|
green = green.toDouble(),
|
||||||
|
blue = blue.toDouble(),
|
||||||
|
alpha = alpha.toDouble(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,43 @@
|
|||||||
|
@file:OptIn(ExperimentalForeignApi::class)
|
||||||
|
|
||||||
package ru.fromchat.ui.calls
|
package ru.fromchat.ui.calls
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.viewinterop.UIKitView
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.ObjCSignatureOverride
|
||||||
|
import platform.Foundation.NSError
|
||||||
|
import platform.WebKit.WKNavigation
|
||||||
|
import platform.WebKit.WKNavigationDelegateProtocol
|
||||||
|
import platform.WebKit.WKScriptMessage
|
||||||
|
import platform.WebKit.WKScriptMessageHandlerProtocol
|
||||||
|
import platform.WebKit.WKUserContentController
|
||||||
|
import platform.WebKit.WKWebView
|
||||||
|
import platform.WebKit.WKWebViewConfiguration
|
||||||
|
import platform.WebKit.WKAudiovisualMediaTypeNone
|
||||||
|
import platform.CoreGraphics.CGRectMake
|
||||||
|
import platform.darwin.NSObject
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
import ru.fromchat.api.calls.CallStore
|
||||||
import ru.fromchat.api.calls.LiveKitConnectSession
|
import ru.fromchat.api.calls.LiveKitConnectSession
|
||||||
|
|
||||||
|
private const val TAG = "CallMediaLayer"
|
||||||
|
private const val HANDLER_NAME = "FromChatCall"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun CallMediaLayer(
|
actual fun CallMediaLayer(
|
||||||
connect: LiveKitConnectSession?,
|
connect: LiveKitConnectSession?,
|
||||||
@@ -13,5 +45,182 @@ actual fun CallMediaLayer(
|
|||||||
showInCallControls: Boolean,
|
showInCallControls: Boolean,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
) {
|
) {
|
||||||
Box(modifier = modifier.fillMaxSize())
|
when {
|
||||||
|
connect == null && showDialingPlaceholder -> {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connect != null -> {
|
||||||
|
key(connect.roomName) {
|
||||||
|
LiveKitWkWebCall(
|
||||||
|
session = connect,
|
||||||
|
showInCallControls = showInCallControls,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Box(modifier = modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LiveKitWkWebCall(
|
||||||
|
session: LiveKitConnectSession,
|
||||||
|
showInCallControls: Boolean,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
var micOn by remember(session.roomName) { mutableStateOf(true) }
|
||||||
|
var camOn by remember(session.roomName) { mutableStateOf(true) }
|
||||||
|
val sessionState = rememberUpdatedState(session)
|
||||||
|
val heldWebView = remember { arrayOfNulls<WKWebView>(1) }
|
||||||
|
val instanceId = remember { (100000..999999).random().toString(16) }
|
||||||
|
|
||||||
|
val messageHandler = remember {
|
||||||
|
object : NSObject(), WKScriptMessageHandlerProtocol {
|
||||||
|
override fun userContentController(
|
||||||
|
userContentController: WKUserContentController,
|
||||||
|
didReceiveScriptMessage: WKScriptMessage,
|
||||||
|
) {
|
||||||
|
if (didReceiveScriptMessage.name != HANDLER_NAME) return
|
||||||
|
val map = didReceiveScriptMessage.body as? Map<*, *> ?: return
|
||||||
|
when (map["method"] as? String) {
|
||||||
|
"onConnected" -> {
|
||||||
|
Logger.i(TAG, "LiveKit JS connected id=$instanceId room=${sessionState.value.roomName}")
|
||||||
|
}
|
||||||
|
"onConnectFailed" -> {
|
||||||
|
val message = (map["message"] as? String).orEmpty()
|
||||||
|
Logger.e(TAG, "LiveKit JS connect failed id=$instanceId: $message")
|
||||||
|
CallStore.onLiveKitConnectFailed(sessionState.value, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val navDelegate = remember {
|
||||||
|
object : NSObject(), WKNavigationDelegateProtocol {
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) {
|
||||||
|
Logger.i(TAG, "didFinishNavigation id=$instanceId → connect")
|
||||||
|
webView.evaluateJavaScript(
|
||||||
|
LiveKitCallWebPage.connectScript(
|
||||||
|
sessionState.value.serverUrl,
|
||||||
|
sessionState.value.token,
|
||||||
|
),
|
||||||
|
completionHandler = { _, error ->
|
||||||
|
if (error != null) {
|
||||||
|
Logger.e(
|
||||||
|
TAG,
|
||||||
|
"connect script failed id=$instanceId: ${error.localizedDescription}",
|
||||||
|
)
|
||||||
|
CallStore.onLiveKitConnectFailed(
|
||||||
|
sessionState.value,
|
||||||
|
error.localizedDescription,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun webView(
|
||||||
|
webView: WKWebView,
|
||||||
|
didFailNavigation: WKNavigation?,
|
||||||
|
withError: NSError,
|
||||||
|
) {
|
||||||
|
Logger.w(TAG, "didFailNavigation id=$instanceId: ${withError.localizedDescription}")
|
||||||
|
CallStore.onLiveKitConnectFailed(sessionState.value, withError.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjCSignatureOverride
|
||||||
|
override fun webView(
|
||||||
|
webView: WKWebView,
|
||||||
|
didFailProvisionalNavigation: WKNavigation?,
|
||||||
|
withError: NSError,
|
||||||
|
) {
|
||||||
|
Logger.w(
|
||||||
|
TAG,
|
||||||
|
"didFailProvisionalNavigation id=$instanceId: ${withError.localizedDescription}",
|
||||||
|
)
|
||||||
|
CallStore.onLiveKitConnectFailed(sessionState.value, withError.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(session.roomName) {
|
||||||
|
Logger.i(TAG, "WKWebView call enter id=$instanceId room=${session.roomName}")
|
||||||
|
onDispose {
|
||||||
|
Logger.i(TAG, "WKWebView call dispose id=$instanceId")
|
||||||
|
heldWebView[0]?.let { wv ->
|
||||||
|
wv.evaluateJavaScript(LiveKitCallWebPage.disconnectScript(), null)
|
||||||
|
wv.configuration.userContentController
|
||||||
|
.removeScriptMessageHandlerForName(HANDLER_NAME)
|
||||||
|
wv.setNavigationDelegate(null)
|
||||||
|
wv.stopLoading()
|
||||||
|
}
|
||||||
|
heldWebView[0] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
UIKitView(
|
||||||
|
factory = {
|
||||||
|
val contentController = WKUserContentController().apply {
|
||||||
|
addScriptMessageHandler(messageHandler, name = HANDLER_NAME)
|
||||||
|
}
|
||||||
|
val config = WKWebViewConfiguration().apply {
|
||||||
|
setUserContentController(contentController)
|
||||||
|
allowsInlineMediaPlayback = true
|
||||||
|
mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone
|
||||||
|
}
|
||||||
|
WKWebView(
|
||||||
|
frame = CGRectMake(0.0, 0.0, 0.0, 0.0),
|
||||||
|
configuration = config,
|
||||||
|
).apply {
|
||||||
|
opaque = true
|
||||||
|
setNavigationDelegate(navDelegate)
|
||||||
|
loadHTMLString(
|
||||||
|
LiveKitCallWebPage.html,
|
||||||
|
baseURL = platform.Foundation.NSURL(string = "https://cdn.jsdelivr.net/"),
|
||||||
|
)
|
||||||
|
heldWebView[0] = this
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
update = { wv ->
|
||||||
|
heldWebView[0] = wv
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showInCallControls) {
|
||||||
|
WebCallInCallControls(
|
||||||
|
micOn = micOn,
|
||||||
|
camOn = camOn,
|
||||||
|
onMicToggle = {
|
||||||
|
val next = !micOn
|
||||||
|
micOn = next
|
||||||
|
heldWebView[0]?.evaluateJavaScript(
|
||||||
|
LiveKitCallWebPage.setMicScript(next),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onCamToggle = {
|
||||||
|
val next = !camOn
|
||||||
|
camOn = next
|
||||||
|
heldWebView[0]?.evaluateJavaScript(
|
||||||
|
LiveKitCallWebPage.setCamScript(next),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onEndCall = { CallStore.endCall() },
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
package ru.fromchat.ui.calls
|
package ru.fromchat.ui.calls
|
||||||
|
|
||||||
actual val UseInlineInCallChrome: Boolean = false
|
/**
|
||||||
|
* When true, [CallMediaLayer] owns in-call mic/camera/end chrome (Android native +
|
||||||
|
* WebView-based iOS/desktop). Call overlays still pass [showInCallControls].
|
||||||
|
*/
|
||||||
|
actual val UseInlineInCallChrome: Boolean = true
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.fromchat.ui.calls
|
||||||
|
|
||||||
|
internal actual fun notifyIncomingCall(title: String, body: String) = Unit
|
||||||
@@ -1,21 +1,218 @@
|
|||||||
|
@file:OptIn(ExperimentalForeignApi::class)
|
||||||
|
|
||||||
package ru.fromchat.ui.chat.utils
|
package ru.fromchat.ui.chat.utils
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.useContents
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import platform.Foundation.NSCachesDirectory
|
||||||
|
import platform.Foundation.NSFileManager
|
||||||
|
import platform.Foundation.NSLock
|
||||||
|
import platform.Foundation.NSSearchPathForDirectoriesInDomains
|
||||||
|
import platform.Foundation.NSURL
|
||||||
|
import platform.Foundation.NSUUID
|
||||||
|
import platform.Foundation.NSUserDomainMask
|
||||||
|
import platform.PhotosUI.PHPickerConfiguration
|
||||||
|
import platform.PhotosUI.PHPickerFilter
|
||||||
|
import platform.PhotosUI.PHPickerResult
|
||||||
|
import platform.PhotosUI.PHPickerViewController
|
||||||
|
import platform.PhotosUI.PHPickerViewControllerDelegateProtocol
|
||||||
|
import platform.UIKit.UIDocumentPickerDelegateProtocol
|
||||||
|
import platform.UIKit.UIDocumentPickerViewController
|
||||||
|
import platform.UIKit.UIImage
|
||||||
|
import platform.UniformTypeIdentifiers.UTTypeImage
|
||||||
|
import platform.UniformTypeIdentifiers.UTTypeItem
|
||||||
|
import platform.darwin.NSObject
|
||||||
|
import platform.darwin.dispatch_async
|
||||||
|
import platform.darwin.dispatch_get_main_queue
|
||||||
|
import ru.fromchat.utils.iosTopViewController
|
||||||
|
|
||||||
actual fun getFilenameFromUri(uri: String): String {
|
actual fun getFilenameFromUri(uri: String): String {
|
||||||
return uri.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
|
val path = when {
|
||||||
|
uri.startsWith("file://") -> NSURL.URLWithString(uri)?.path ?: uri
|
||||||
|
else -> uri
|
||||||
|
}
|
||||||
|
return path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit {
|
actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit {
|
||||||
return { /* Phase 4: PHPickerViewController */ }
|
var activeDelegate by remember { mutableStateOf<ImagePickerDelegate?>(null) }
|
||||||
|
return remember(onResult) {
|
||||||
|
{
|
||||||
|
val host = iosTopViewController() ?: return@remember
|
||||||
|
val picker = PHPickerViewController(
|
||||||
|
configuration = PHPickerConfiguration().apply {
|
||||||
|
filter = PHPickerFilter.imagesFilter
|
||||||
|
selectionLimit = 0
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val delegate = ImagePickerDelegate { uris ->
|
||||||
|
activeDelegate = null
|
||||||
|
onResult(uris)
|
||||||
|
}
|
||||||
|
activeDelegate = delegate
|
||||||
|
picker.delegate = delegate
|
||||||
|
host.presentViewController(picker, animated = true, completion = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
|
actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
|
||||||
return { /* Phase 4: UIDocumentPickerViewController */ }
|
var activeDelegate by remember { mutableStateOf<FilePickerDelegate?>(null) }
|
||||||
|
return remember(onResult) {
|
||||||
|
{
|
||||||
|
val host = iosTopViewController() ?: return@remember
|
||||||
|
val picker = UIDocumentPickerViewController(
|
||||||
|
forOpeningContentTypes = listOf(UTTypeItem),
|
||||||
|
asCopy = true,
|
||||||
|
).apply {
|
||||||
|
allowsMultipleSelection = true
|
||||||
|
}
|
||||||
|
val delegate = FilePickerDelegate { uris ->
|
||||||
|
activeDelegate = null
|
||||||
|
onResult(uris)
|
||||||
|
}
|
||||||
|
activeDelegate = delegate
|
||||||
|
picker.delegate = delegate
|
||||||
|
host.presentViewController(picker, animated = true, completion = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun getImageAspectRatio(uri: String): Float? = null
|
actual suspend fun getImageAspectRatio(uri: String): Float? {
|
||||||
|
val (w, h) = getImageDimensions(uri) ?: return null
|
||||||
|
if (h == 0) return null
|
||||||
|
return w.toFloat() / h.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
actual suspend fun getImageDimensions(uri: String): Pair<Int, Int>? = null
|
actual suspend fun getImageDimensions(uri: String): Pair<Int, Int>? = withContext(Dispatchers.Default) {
|
||||||
|
val path = when {
|
||||||
|
uri.startsWith("file://") -> NSURL.URLWithString(uri)?.path
|
||||||
|
else -> uri
|
||||||
|
} ?: return@withContext null
|
||||||
|
runCatching {
|
||||||
|
val image = UIImage.imageWithContentsOfFile(path) ?: return@runCatching null
|
||||||
|
pixelSize(image)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pixelSize(image: UIImage): Pair<Int, Int>? {
|
||||||
|
val w = (image.size.useContents { width } * image.scale).toInt()
|
||||||
|
val h = (image.size.useContents { height } * image.scale).toInt()
|
||||||
|
if (w <= 0 || h <= 0) return null
|
||||||
|
return w to h
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pickerCacheDirectory(): String {
|
||||||
|
val caches = NSSearchPathForDirectoriesInDomains(
|
||||||
|
NSCachesDirectory,
|
||||||
|
NSUserDomainMask,
|
||||||
|
true,
|
||||||
|
).filterIsInstance<String>().firstOrNull().orEmpty()
|
||||||
|
val dir = "$caches/fromchat/picker"
|
||||||
|
NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyUrlIntoPickerCache(url: NSURL): String? {
|
||||||
|
val name = url.lastPathComponent?.takeIf { it.isNotBlank() } ?: "file"
|
||||||
|
val destPath = "${pickerCacheDirectory()}/${NSUUID().UUIDString}_$name"
|
||||||
|
val destUrl = NSURL.fileURLWithPath(destPath)
|
||||||
|
NSFileManager.defaultManager.removeItemAtPath(destPath, null)
|
||||||
|
if (!NSFileManager.defaultManager.copyItemAtURL(url, toURL = destUrl, error = null)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return destUrl.absoluteString
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ImagePickerDelegate(
|
||||||
|
private val onFinished: (List<String>) -> Unit,
|
||||||
|
) : NSObject(), PHPickerViewControllerDelegateProtocol {
|
||||||
|
private var finished = false
|
||||||
|
private val lock = NSLock()
|
||||||
|
|
||||||
|
override fun picker(picker: PHPickerViewController, didFinishPicking: List<*>) {
|
||||||
|
picker.dismissViewControllerAnimated(true, completion = null)
|
||||||
|
val results = didFinishPicking.filterIsInstance<PHPickerResult>()
|
||||||
|
if (results.isEmpty()) {
|
||||||
|
finish(emptyList())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val collected = arrayOfNulls<String>(results.size)
|
||||||
|
var remaining = results.size
|
||||||
|
val typeId = UTTypeImage.identifier
|
||||||
|
results.forEachIndexed { index, result ->
|
||||||
|
val provider = result.itemProvider
|
||||||
|
if (!provider.hasItemConformingToTypeIdentifier(typeId)) {
|
||||||
|
val done = onItemLoaded {
|
||||||
|
remaining -= 1
|
||||||
|
remaining == 0
|
||||||
|
}
|
||||||
|
if (done) finish(collected.filterNotNull())
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
provider.loadFileRepresentationForTypeIdentifier(typeId) { url, _ ->
|
||||||
|
collected[index] = url?.let { copyUrlIntoPickerCache(it) }
|
||||||
|
val done = onItemLoaded {
|
||||||
|
remaining -= 1
|
||||||
|
remaining == 0
|
||||||
|
}
|
||||||
|
if (done) {
|
||||||
|
dispatch_async(dispatch_get_main_queue()) {
|
||||||
|
finish(collected.filterNotNull())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun onItemLoaded(block: () -> Boolean): Boolean {
|
||||||
|
lock.lock()
|
||||||
|
return try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(uris: List<String>) {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
onFinished(uris)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FilePickerDelegate(
|
||||||
|
private val onFinished: (List<String>) -> Unit,
|
||||||
|
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
private fun finish(uris: List<String>) {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
onFinished(uris)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun documentPicker(
|
||||||
|
controller: UIDocumentPickerViewController,
|
||||||
|
didPickDocumentsAtURLs: List<*>,
|
||||||
|
) {
|
||||||
|
val uris = didPickDocumentsAtURLs.mapNotNull { raw ->
|
||||||
|
val url = raw as? NSURL ?: return@mapNotNull null
|
||||||
|
copyUrlIntoPickerCache(url) ?: url.absoluteString
|
||||||
|
}
|
||||||
|
finish(uris)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) {
|
||||||
|
finish(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+4
-6
@@ -1,4 +1,4 @@
|
|||||||
package ru.fromchat.ui
|
package ru.fromchat.ui.components
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -14,8 +14,8 @@ actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
|
|||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
|
actual fun rememberHapticFeedbackInternal(): (Int) -> Unit =
|
||||||
return remember {
|
remember {
|
||||||
{ ordinal ->
|
{ ordinal ->
|
||||||
val style: UIImpactFeedbackStyle = when (ordinal) {
|
val style: UIImpactFeedbackStyle = when (ordinal) {
|
||||||
HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
|
HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
|
||||||
@@ -23,8 +23,6 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
|
|||||||
HapticFeedbackEvent.SelectionModeEntered.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
|
HapticFeedbackEvent.SelectionModeEntered.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
|
||||||
else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight
|
else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight
|
||||||
}
|
}
|
||||||
val generator = UIImpactFeedbackGenerator(style)
|
UIImpactFeedbackGenerator(style).impactOccurred()
|
||||||
generator.impactOccurred()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package ru.fromchat.ui.debug
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
actual fun DebugApiScreen() {
|
|
||||||
DebugApiScreenContent()
|
|
||||||
}
|
|
||||||
+3
-3
@@ -11,6 +11,6 @@ actual fun openAppNotificationSettings(): Boolean {
|
|||||||
return UIApplication.sharedApplication.openURL(url)
|
return UIApplication.sharedApplication.openURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun areAppNotificationsEnabled(): Boolean {
|
actual fun areAppNotificationsEnabled(): Boolean = false
|
||||||
return true
|
|
||||||
}
|
actual fun arePushNotificationsSupported(): Boolean = false
|
||||||
@@ -1,15 +1,44 @@
|
|||||||
|
@file:OptIn(ExperimentalForeignApi::class)
|
||||||
|
|
||||||
package ru.fromchat.utils
|
package ru.fromchat.utils
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import platform.Network.nw_path_get_status
|
||||||
|
import platform.Network.nw_path_monitor_create
|
||||||
|
import platform.Network.nw_path_monitor_set_queue
|
||||||
|
import platform.Network.nw_path_monitor_set_update_handler
|
||||||
|
import platform.Network.nw_path_monitor_start
|
||||||
|
import platform.Network.nw_path_monitor_t
|
||||||
|
import platform.Network.nw_path_status_satisfied
|
||||||
|
import platform.darwin.dispatch_get_main_queue
|
||||||
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
|
|
||||||
// TODO iOS: wired later to NWPathMonitor; default [true] avoids false "offline" when APIs are absent.
|
|
||||||
actual object NetworkConnectivity {
|
actual object NetworkConnectivity {
|
||||||
private val _isOnline = MutableStateFlow(true)
|
private val _isOnline = MutableStateFlow(true)
|
||||||
actual val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
|
actual val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
|
||||||
|
|
||||||
|
private var started = false
|
||||||
|
// Retained so the path monitor is not collected while the process is alive.
|
||||||
|
private var monitor: nw_path_monitor_t = null
|
||||||
|
|
||||||
actual fun ensureStarted() {
|
actual fun ensureStarted() {
|
||||||
// No-op until native path monitoring is bound.
|
if (started) return
|
||||||
|
started = true
|
||||||
|
val pathMonitor = nw_path_monitor_create() ?: return
|
||||||
|
monitor = pathMonitor
|
||||||
|
nw_path_monitor_set_update_handler(pathMonitor) { path ->
|
||||||
|
val online = nw_path_get_status(path) == nw_path_status_satisfied
|
||||||
|
val wasOnline = _isOnline.value
|
||||||
|
_isOnline.value = online
|
||||||
|
when {
|
||||||
|
online && !wasOnline -> WebSocketManager.onNetworkAvailable()
|
||||||
|
!online && wasOnline -> WebSocketManager.onNetworkLost()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nw_path_monitor_set_queue(pathMonitor, dispatch_get_main_queue())
|
||||||
|
nw_path_monitor_start(pathMonitor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.fromchat
|
||||||
|
|
||||||
|
actual fun keepWebSocketAliveInBackground(): Boolean = true
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package ru.fromchat
|
||||||
|
|
||||||
|
import ru.fromchat.logging.AppLogLevel
|
||||||
|
import ru.fromchat.logging.AppLogStore
|
||||||
|
|
||||||
|
actual object Logger {
|
||||||
|
actual fun d(tag: String, message: String, throwable: Throwable?) {
|
||||||
|
AppLogStore.record(AppLogLevel.Debug, tag, message, throwable)
|
||||||
|
println("DEBUG: [$tag] $message ${throwable?.message ?: ""}")
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun i(tag: String, message: String, throwable: Throwable?) {
|
||||||
|
AppLogStore.record(AppLogLevel.Info, tag, message, throwable)
|
||||||
|
println("INFO: [$tag] $message ${throwable?.message ?: ""}")
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun w(tag: String, message: String, throwable: Throwable?) {
|
||||||
|
AppLogStore.record(AppLogLevel.Warn, tag, message, throwable)
|
||||||
|
System.err.println("WARN: [$tag] $message ${throwable?.message ?: ""}")
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun e(tag: String, message: String, throwable: Throwable?) {
|
||||||
|
AppLogStore.record(AppLogLevel.Error, tag, message, throwable)
|
||||||
|
System.err.println("ERROR: [$tag] $message ${throwable?.message ?: ""}")
|
||||||
|
throwable?.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun f(tag: String, message: String, throwable: Throwable?) {
|
||||||
|
AppLogStore.record(AppLogLevel.Fatal, tag, message, throwable)
|
||||||
|
System.err.println("FATAL: [$tag] $message ${throwable?.message ?: ""}")
|
||||||
|
throwable?.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package ru.fromchat.api
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.HttpClientConfig
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
|
import io.ktor.client.plugins.defaultRequest
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
|
||||||
|
actual fun createPlatformHttpClient(
|
||||||
|
block: HttpClientConfig<*>.() -> Unit
|
||||||
|
): HttpClient {
|
||||||
|
return HttpClient(CIO) {
|
||||||
|
defaultRequest {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
|
||||||
|
headers {
|
||||||
|
append("Accept-Charset", "utf-8")
|
||||||
|
append("Content-Type", "application/json; charset=utf-8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = 30_000
|
||||||
|
connectTimeoutMillis = 5_000
|
||||||
|
socketTimeoutMillis = 30_000
|
||||||
|
}
|
||||||
|
|
||||||
|
block(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.fromchat.api
|
||||||
|
|
||||||
|
actual suspend fun syncPushTokenAfterStartup() = Unit
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.fromchat.api
|
||||||
|
|
||||||
|
actual suspend fun uploadPendingFcmTokenIfAvailable() = Unit
|
||||||
|
|
||||||
|
actual suspend fun ensureFcmTokenRegistered(): Boolean = false
|
||||||
|
|
||||||
|
actual suspend fun unregisterFcmTokenFromServer(): Boolean = false
|
||||||
|
|
||||||
|
actual suspend fun isFcmPushRegisteredLocally(): Boolean = false
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package ru.fromchat.api.crypto.backup
|
||||||
|
|
||||||
|
import dev.whyoleg.cryptography.BinarySize.Companion.bits
|
||||||
|
import dev.whyoleg.cryptography.BinarySize.Companion.bytes
|
||||||
|
import dev.whyoleg.cryptography.CryptographyProvider
|
||||||
|
import dev.whyoleg.cryptography.DelicateCryptographyApi
|
||||||
|
import dev.whyoleg.cryptography.algorithms.AES
|
||||||
|
import dev.whyoleg.cryptography.algorithms.PBKDF2
|
||||||
|
import dev.whyoleg.cryptography.algorithms.SHA256
|
||||||
|
import dev.whyoleg.cryptography.random.CryptographyRandom
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private const val PBKDF2_ITERATIONS = 210_000
|
||||||
|
private const val IV_SIZE = 12
|
||||||
|
private const val KEY_SIZE_BYTES = 32
|
||||||
|
|
||||||
|
@OptIn(DelicateCryptographyApi::class)
|
||||||
|
actual object BackupCrypto {
|
||||||
|
private val provider get() = CryptographyProvider.Default
|
||||||
|
private val aesGcm get() = provider.get(AES.GCM)
|
||||||
|
private val pbkdf2 get() = provider.get(PBKDF2)
|
||||||
|
|
||||||
|
actual suspend fun encryptBackupWithPassword(
|
||||||
|
password: String,
|
||||||
|
bundle: PrivateKeyBundle
|
||||||
|
): EncryptedBackupBlob = withContext(Dispatchers.Default) {
|
||||||
|
val salt = CryptographyRandom.nextBytes(16)
|
||||||
|
val nonce = CryptographyRandom.nextBytes(IV_SIZE)
|
||||||
|
|
||||||
|
EncryptedBackupBlob(
|
||||||
|
salt,
|
||||||
|
nonce,
|
||||||
|
aesGcm
|
||||||
|
.keyDecoder()
|
||||||
|
.decodeFromByteArray(
|
||||||
|
AES.Key.Format.RAW,
|
||||||
|
pbkdf2
|
||||||
|
.secretDerivation(
|
||||||
|
digest = SHA256,
|
||||||
|
iterations = PBKDF2_ITERATIONS,
|
||||||
|
outputSize = KEY_SIZE_BYTES.bytes,
|
||||||
|
salt = salt
|
||||||
|
)
|
||||||
|
.deriveSecretBlocking(
|
||||||
|
password.encodeToByteArray()
|
||||||
|
)
|
||||||
|
.toByteArray()
|
||||||
|
)
|
||||||
|
.cipher(tagSize = 128.bits)
|
||||||
|
.encryptWithIv(nonce, serializeBundle(bundle))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun decryptBackupWithPassword(
|
||||||
|
password: String,
|
||||||
|
blob: EncryptedBackupBlob
|
||||||
|
): PrivateKeyBundle = withContext(Dispatchers.Default) {
|
||||||
|
deserializeBundle(
|
||||||
|
aesGcm
|
||||||
|
.keyDecoder()
|
||||||
|
.decodeFromByteArray(
|
||||||
|
AES.Key.Format.RAW,
|
||||||
|
pbkdf2
|
||||||
|
.secretDerivation(
|
||||||
|
digest = SHA256,
|
||||||
|
iterations = PBKDF2_ITERATIONS,
|
||||||
|
outputSize = KEY_SIZE_BYTES.bytes,
|
||||||
|
salt = blob.salt
|
||||||
|
)
|
||||||
|
.deriveSecretBlocking(
|
||||||
|
password.encodeToByteArray()
|
||||||
|
)
|
||||||
|
.toByteArray()
|
||||||
|
)
|
||||||
|
.cipher(tagSize = 128.bits)
|
||||||
|
.decryptWithIv(blob.iv, blob.ciphertext)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun randomBytes(length: Int): ByteArray = CryptographyRandom.nextBytes(length)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package ru.fromchat.api.crypto.dm
|
||||||
|
|
||||||
|
import com.pr0gramm3r101.utils.crypto.Base64
|
||||||
|
import com.pr0gramm3r101.utils.require
|
||||||
|
import dev.whyoleg.cryptography.BinarySize.Companion.bits
|
||||||
|
import dev.whyoleg.cryptography.CryptographyProvider
|
||||||
|
import dev.whyoleg.cryptography.DelicateCryptographyApi
|
||||||
|
import dev.whyoleg.cryptography.algorithms.AES
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.fromchat.api.crypto.DmCiphertextCorruptedException
|
||||||
|
|
||||||
|
private const val AES_KEY_SIZE = 32
|
||||||
|
private const val GCM_IV_SIZE = 12
|
||||||
|
private const val GCM_TAG_SIZE = 16
|
||||||
|
|
||||||
|
@OptIn(DelicateCryptographyApi::class)
|
||||||
|
actual object DmCrypto {
|
||||||
|
private val provider get() = CryptographyProvider.Default
|
||||||
|
private val aesGcm get() = provider.get(AES.GCM)
|
||||||
|
|
||||||
|
actual suspend fun unwrapMek(
|
||||||
|
wrappedMekB64: String,
|
||||||
|
wrappingKey: ByteArray
|
||||||
|
) = withContext(Dispatchers.Default) {
|
||||||
|
require(wrappingKey.size == AES_KEY_SIZE) { "Wrapping key must be 32 bytes" }
|
||||||
|
val wrapped = Base64.decode(wrappedMekB64)
|
||||||
|
require(wrapped.size >= GCM_IV_SIZE + GCM_TAG_SIZE) { "Wrapped MEK too short" }
|
||||||
|
|
||||||
|
try {
|
||||||
|
aesGcmDecrypt(
|
||||||
|
wrappingKey,
|
||||||
|
wrapped.sliceArray(0 until GCM_IV_SIZE),
|
||||||
|
wrapped.sliceArray(GCM_IV_SIZE until wrapped.size)
|
||||||
|
)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
throw DmCiphertextCorruptedException(cause = e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun decryptEnvelope(
|
||||||
|
ivB64: String,
|
||||||
|
ciphertextB64: String,
|
||||||
|
mek: ByteArray,
|
||||||
|
) = withContext(Dispatchers.Default) {
|
||||||
|
val key = mek.require("MEK must be 32 bytes") {
|
||||||
|
it.size == AES_KEY_SIZE
|
||||||
|
}
|
||||||
|
val iv = Base64
|
||||||
|
.decode(ivB64)
|
||||||
|
.require("IV must be 12 bytes") {
|
||||||
|
it.size == GCM_IV_SIZE
|
||||||
|
}
|
||||||
|
val ciphertext = Base64
|
||||||
|
.decode(ciphertextB64)
|
||||||
|
.require("Ciphertext too short") {
|
||||||
|
it.size >= GCM_TAG_SIZE
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
aesGcmDecrypt(key, iv, ciphertext)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
throw DmCiphertextCorruptedException(cause = e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun decryptAesGcm(
|
||||||
|
ivB64: String,
|
||||||
|
ciphertext: ByteArray,
|
||||||
|
mek: ByteArray,
|
||||||
|
) = withContext(Dispatchers.Default) {
|
||||||
|
val key = mek.require("MEK must be 32 bytes") {
|
||||||
|
it.size == AES_KEY_SIZE
|
||||||
|
}
|
||||||
|
val iv = Base64
|
||||||
|
.decode(ivB64)
|
||||||
|
.require("IV must be 12 bytes") {
|
||||||
|
it.size == GCM_IV_SIZE
|
||||||
|
}
|
||||||
|
ciphertext.require("Ciphertext too short") {
|
||||||
|
it.size >= GCM_TAG_SIZE
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
aesGcmDecrypt(key, iv, ciphertext)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
throw DmCiphertextCorruptedException(cause = e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun decryptAesGcmFileToPath(
|
||||||
|
ivB64: String,
|
||||||
|
encryptedFilePath: String,
|
||||||
|
mek: ByteArray,
|
||||||
|
outputPath: String,
|
||||||
|
): Long = withContext(Dispatchers.Default) {
|
||||||
|
val iv = Base64
|
||||||
|
.decode(ivB64)
|
||||||
|
.require("IV must be 12 bytes") {
|
||||||
|
it.size == GCM_IV_SIZE
|
||||||
|
}
|
||||||
|
DmFileOps.aesGcmDecryptFileToPath(iv, encryptedFilePath, mek, outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): ByteArray {
|
||||||
|
require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes for GCM" }
|
||||||
|
require(key.size == AES_KEY_SIZE) { "Key must be 32 bytes" }
|
||||||
|
require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" }
|
||||||
|
|
||||||
|
return aesGcm
|
||||||
|
.keyDecoder()
|
||||||
|
.decodeFromByteArray(
|
||||||
|
AES.Key.Format.RAW,
|
||||||
|
key
|
||||||
|
)
|
||||||
|
.cipher(tagSize = 128.bits)
|
||||||
|
.decryptWithIv(iv, ciphertext)
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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)
|
||||||
|
}
|
||||||
+31
@@ -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)
|
||||||
|
}
|
||||||
+5
@@ -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
|
||||||
+27
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -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)
|
||||||
|
}
|
||||||
+351
@@ -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
|
||||||
+23
@@ -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
|
||||||
|
}
|
||||||
+9
@@ -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)
|
||||||
|
}
|
||||||
+15
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -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()
|
||||||
|
}
|
||||||
+17
@@ -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
|
||||||
+6
@@ -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
|
||||||
|
}
|
||||||
+15
@@ -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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user