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