mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Fix SQLite thread safety, add "What's new", single source of truth for app version, improve database lock UI
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
- Наконец-то появилась десктопная версия FromChat! Теперь вы можете общаться прямо с вашего компьютера на Windows, Linux и macOS. Даже старые Mac на Intel поддерживаются.
|
||||
- На Android и десктопе вы теперь можете напрямую перетаскивать файлы и картинки прямо в чат без необходимости долго их искать.
|
||||
- Улучшен экран устройств: теперь более наглядно видно, на каких устройствах вы вошли в аккаунт. И иконки теперь везде одинаковые.
|
||||
@@ -19,38 +19,73 @@ object DatabaseLockGate {
|
||||
private val _processes = MutableStateFlow<List<LockingProcessInfo>>(emptyList())
|
||||
val processes: StateFlow<List<LockingProcessInfo>> = _processes.asStateFlow()
|
||||
|
||||
private val _runtimeSqliteBusy = MutableStateFlow(false)
|
||||
|
||||
private var monitorJob: Job? = null
|
||||
|
||||
fun onSqliteBusy(throwable: Throwable?): Boolean {
|
||||
Logger.w("DatabaseLockGate", "SQLite busy", throwable)
|
||||
_runtimeSqliteBusy.value = true
|
||||
return refreshBlockedState()
|
||||
}
|
||||
|
||||
fun startMonitoring(scope: CoroutineScope, onUnlocked: () -> Unit) {
|
||||
monitorJob?.cancel()
|
||||
monitorJob = scope.launch {
|
||||
var everBlocked = false
|
||||
var bootstrapDelivered = false
|
||||
while (isActive) {
|
||||
val locked = MessageDatabasePaths.isDatabaseLocked()
|
||||
if (locked) {
|
||||
everBlocked = true
|
||||
_blocked.value = true
|
||||
_processes.value = DatabaseLockingProcessResolver.findLockingProcesses(
|
||||
MessageDatabasePaths.lockTargetFiles(),
|
||||
)
|
||||
} else {
|
||||
if (_blocked.value) {
|
||||
Logger.i("DatabaseLockGate", "database unlocked; resuming startup")
|
||||
}
|
||||
_blocked.value = false
|
||||
_processes.value = emptyList()
|
||||
if (!bootstrapDelivered) {
|
||||
bootstrapDelivered = true
|
||||
onUnlocked()
|
||||
}
|
||||
if (!everBlocked) return@launch
|
||||
if (!_blocked.value) return@launch
|
||||
val blocked = refreshBlockedState()
|
||||
if (!blocked && !bootstrapDelivered) {
|
||||
bootstrapDelivered = true
|
||||
onUnlocked()
|
||||
}
|
||||
if (!blocked && bootstrapDelivered) return@launch
|
||||
delay(750.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun killProcess(pid: Long): Boolean = DatabaseLockingProcessResolver.killProcess(pid)
|
||||
|
||||
fun releaseRuntimeLock() {
|
||||
MessageDatabaseRuntimeLock.release()
|
||||
}
|
||||
|
||||
private fun refreshBlockedState(): Boolean {
|
||||
val externalProcesses = DatabaseLockingProcessResolver.findLockingProcesses(
|
||||
MessageDatabasePaths.lockProbeFiles(),
|
||||
)
|
||||
|
||||
if (!MessageDatabaseRuntimeLock.isHeld() && !MessageDatabaseRuntimeLock.tryAcquire()) {
|
||||
Logger.w(
|
||||
"DatabaseLockGate",
|
||||
"Instance lock held by another process; blockers=$externalProcesses",
|
||||
)
|
||||
_blocked.value = true
|
||||
_processes.value = externalProcesses
|
||||
return true
|
||||
}
|
||||
|
||||
if (externalProcesses.isNotEmpty()) {
|
||||
Logger.w(
|
||||
"DatabaseLockGate",
|
||||
"Database files in use by other process(es): $externalProcesses",
|
||||
)
|
||||
_blocked.value = true
|
||||
_processes.value = externalProcesses
|
||||
return true
|
||||
}
|
||||
|
||||
if (_runtimeSqliteBusy.value) {
|
||||
Logger.i(
|
||||
"DatabaseLockGate",
|
||||
"SQLite busy with no external locker — treating as in-process contention, not blocking",
|
||||
)
|
||||
_runtimeSqliteBusy.value = false
|
||||
}
|
||||
|
||||
_blocked.value = false
|
||||
_processes.value = emptyList()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,27 +2,31 @@ package ru.fromchat.desktop
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
@@ -30,31 +34,56 @@ import androidx.compose.ui.window.rememberWindowState
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.desktop_database_locked_kill
|
||||
import ru.fromchat.desktop_database_locked_pid
|
||||
import ru.fromchat.desktop_database_locked_message
|
||||
import ru.fromchat.desktop_database_locked_pid
|
||||
import ru.fromchat.desktop_database_locked_title
|
||||
import ru.fromchat.desktop_database_locked_unknown_process
|
||||
import ru.fromchat.desktop_database_locked_unknown_hint
|
||||
import ru.fromchat.desktop_database_locked_waiting
|
||||
import ru.fromchat.desktop_quit
|
||||
import ru.fromchat.ui.FromChatTheme
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveIconFrame
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.components.TextCta
|
||||
import java.awt.image.BufferedImage
|
||||
|
||||
@Composable
|
||||
fun DatabaseLockWindow(
|
||||
appName: String,
|
||||
windowIcon: Painter,
|
||||
dockIconImage: BufferedImage?,
|
||||
processes: List<LockingProcessInfo>,
|
||||
onKillProcess: (Long) -> Unit,
|
||||
onQuit: () -> Unit,
|
||||
) {
|
||||
val title = stringResource(Res.string.desktop_database_locked_title)
|
||||
val windowState = rememberWindowState(width = 520.dp, height = 420.dp)
|
||||
val windows = remember { isWindowsOs() }
|
||||
val windowState = rememberWindowState(width = 420.dp, height = 480.dp)
|
||||
Window(
|
||||
onCloseRequest = onQuit,
|
||||
title = title,
|
||||
title = appName,
|
||||
state = windowState,
|
||||
icon = windowIcon,
|
||||
undecorated = windows,
|
||||
resizable = false,
|
||||
alwaysOnTop = true,
|
||||
) {
|
||||
FromChatTheme(darkTheme = desktopAppDarkTheme()) {
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
DesktopRootSurface(
|
||||
appName = appName,
|
||||
windowIcon = windowIcon,
|
||||
dockIconImage = dockIconImage,
|
||||
windows = windows,
|
||||
windowState = windowState,
|
||||
onCloseRequest = onQuit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
top = if (windows) WindowsTitleBarHeight + 24.dp else 24.dp,
|
||||
start = 32.dp,
|
||||
end = 32.dp,
|
||||
bottom = 24.dp,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DatabaseLockContent(
|
||||
processes = processes,
|
||||
onKillProcess = onKillProcess,
|
||||
@@ -65,80 +94,84 @@ fun DatabaseLockWindow(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun DatabaseLockContent(
|
||||
processes: List<LockingProcessInfo>,
|
||||
onKillProcess: (Long) -> Unit,
|
||||
onQuit: () -> Unit,
|
||||
) {
|
||||
val unknownProcess = stringResource(Res.string.desktop_database_locked_unknown_process)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(min = 480.dp, max = 520.dp)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
.widthIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_database_locked_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
}
|
||||
ExpressiveIconFrame(
|
||||
icon = Icons.Default.Warning,
|
||||
containerSize = 56.dp,
|
||||
iconSize = 28.dp,
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
materialPolygon = MaterialShapes.SoftBurst,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_database_locked_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_database_locked_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (processes.isEmpty()) {
|
||||
LockingProcessRow(
|
||||
process = LockingProcessInfo(
|
||||
pid = -1L,
|
||||
name = unknownProcess,
|
||||
description = null,
|
||||
executablePath = null,
|
||||
icon = null,
|
||||
),
|
||||
killEnabled = false,
|
||||
onKill = {},
|
||||
)
|
||||
} else {
|
||||
processes.forEach { process ->
|
||||
LockingProcessRow(
|
||||
process = process,
|
||||
killEnabled = process.pid > 0L,
|
||||
onKill = { onKillProcess(process.pid) },
|
||||
)
|
||||
|
||||
if (processes.isNotEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
processes.forEach { process ->
|
||||
LockingProcessRow(
|
||||
process = process,
|
||||
onKill = { onKillProcess(process.pid) },
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_database_locked_unknown_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_database_locked_waiting),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
|
||||
TextCta(
|
||||
onClick = onQuit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
OutlinedButton(onClick = onQuit) {
|
||||
Text(stringResource(Res.string.desktop_quit))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_quit),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,64 +179,55 @@ private fun DatabaseLockContent(
|
||||
@Composable
|
||||
private fun LockingProcessRow(
|
||||
process: LockingProcessInfo,
|
||||
killEnabled: Boolean,
|
||||
onKill: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
tonalElevation = 1.dp,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (process.icon != null) {
|
||||
Image(
|
||||
bitmap = process.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (process.icon != null) {
|
||||
Image(
|
||||
bitmap = process.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = process.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
process.description?.let { description ->
|
||||
Text(
|
||||
text = process.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
process.description?.let { description ->
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (process.pid > 0L) {
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_database_locked_pid, process.pid.toInt()),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (killEnabled) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(onClick = onKill) {
|
||||
Text(stringResource(Res.string.desktop_database_locked_kill))
|
||||
}
|
||||
if (process.pid > 0L) {
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_database_locked_pid, process.pid.toInt()),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (process.pid > 0L) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
ActionButton(onClick = onKill) {
|
||||
Text(stringResource(Res.string.desktop_database_locked_kill))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,17 +6,14 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
object DatabaseLockingProcessResolver {
|
||||
fun findLockingProcesses(files: List<File>): List<LockingProcessInfo> {
|
||||
val discovered = buildList {
|
||||
if (isWindowsOs()) {
|
||||
addAll(WindowsRestartManager.findLockingProcesses(files))
|
||||
}
|
||||
if (isMacOs()) {
|
||||
addAll(findWithLsof(files))
|
||||
}
|
||||
if (isLinuxOs()) {
|
||||
addAll(findWithFuser(files))
|
||||
}
|
||||
addAll(ProcessExecutableResolver.findFromChatProcesses())
|
||||
val probeFiles = files
|
||||
.map { file -> runCatching { file.canonicalFile }.getOrDefault(file) }
|
||||
.distinctBy { it.absolutePath }
|
||||
val discovered = when {
|
||||
isWindowsOs() -> WindowsRestartManager.findLockingProcesses(probeFiles)
|
||||
isMacOs() -> findWithLsof(probeFiles.filter { it.exists() })
|
||||
isLinuxOs() -> findWithFuser(probeFiles.filter { it.exists() })
|
||||
else -> emptyList()
|
||||
}
|
||||
return discovered
|
||||
.distinctBy { it.pid }
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.FrameWindowScope
|
||||
import androidx.compose.ui.window.WindowExceptionHandler
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.awt.ComposeWindow
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.api.local.db.isSqliteBusy
|
||||
import ru.fromchat.ui.FromChatTheme
|
||||
import ru.fromchat.ui.LocalExtraStatusBarTop
|
||||
import ru.fromchat.ui.getColorScheme
|
||||
import java.awt.image.BufferedImage
|
||||
|
||||
internal fun desktopThemeBackgroundCompose(): Color =
|
||||
if (desktopAppDarkTheme()) Color(0xFF1C1B1F) else Color(0xFFFFFBFE)
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
internal fun installDesktopWindowExceptionHandler(window: ComposeWindow) {
|
||||
window.exceptionHandler = WindowExceptionHandler { throwable ->
|
||||
if (isSqliteBusy(throwable)) {
|
||||
if (!DatabaseLockGate.onSqliteBusy(throwable)) {
|
||||
Logger.w("DesktopWindow", "Suppressed SQLITE_BUSY in composition", throwable)
|
||||
}
|
||||
} else {
|
||||
Logger.e("DesktopWindow", "uncaught in composition", throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun FrameWindowScope.DesktopRootSurface(
|
||||
appName: String,
|
||||
windowIcon: Painter,
|
||||
dockIconImage: BufferedImage?,
|
||||
windows: Boolean,
|
||||
windowState: WindowState,
|
||||
onCloseRequest: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val windowChrome = desktopThemeBackgroundCompose()
|
||||
|
||||
LaunchedEffect(window, appName, windowChrome) {
|
||||
window.title = appName
|
||||
windowChrome.toAwtColor().also {
|
||||
window.background = it
|
||||
window.contentPane.background = it
|
||||
if (windows) {
|
||||
updateWindowsNativeCaptionBackground(window, it)
|
||||
}
|
||||
}
|
||||
|
||||
if (windows) {
|
||||
installWindowsNativeCaptionChrome(window)
|
||||
applyWindowsRoundedCorners(window)
|
||||
}
|
||||
applyDesktopEdgeToEdgeChrome(window.rootPane)
|
||||
dockIconImage?.let { image ->
|
||||
window.iconImages = listOf(image)
|
||||
applyDockIcon(image)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(window) {
|
||||
installDesktopWindowExceptionHandler(window)
|
||||
onDispose {}
|
||||
}
|
||||
|
||||
FromChatTheme(darkTheme = desktopAppDarkTheme()) {
|
||||
CompositionLocalProvider(
|
||||
LocalExtraStatusBarTop provides when {
|
||||
windows -> WindowsTitleBarHeight
|
||||
else -> 0.dp
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(windowChrome),
|
||||
) {
|
||||
content()
|
||||
if (windows) {
|
||||
MaterialTheme(colorScheme = getColorScheme(desktopAppDarkTheme(), dynamicColor = false)) {
|
||||
WindowsDesktopTitleBar(
|
||||
title = appName,
|
||||
windowIcon = windowIcon,
|
||||
window = window,
|
||||
windowState = windowState,
|
||||
onCloseRequest = onCloseRequest,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.zIndex(10_000f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Color.toAwtColor() =
|
||||
java.awt.Color(red, green, blue, alpha)
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.zIndex
|
||||
@@ -36,7 +35,6 @@ import androidx.compose.ui.window.MenuBar
|
||||
import androidx.compose.ui.window.Notification
|
||||
import androidx.compose.ui.window.Tray
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowExceptionHandler
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberTrayState
|
||||
@@ -56,9 +54,11 @@ import ru.fromchat.action_copy
|
||||
import ru.fromchat.action_select
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.WebSocketManager
|
||||
import ru.fromchat.api.local.db.MessageDatabaseConcurrency
|
||||
import ru.fromchat.api.local.db.isSqliteBusy
|
||||
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
||||
import ru.fromchat.api.local.db.store.ConnectionStatus
|
||||
import ru.fromchat.api.local.db.store.messageDatabaseCoroutineDispatcher
|
||||
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
||||
import ru.fromchat.app_name
|
||||
import ru.fromchat.app_name_beta
|
||||
@@ -120,7 +120,19 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
|
||||
private object DesktopApplicationBootstrap {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val scope = CoroutineScope(
|
||||
SupervisorJob() +
|
||||
messageDatabaseCoroutineDispatcher +
|
||||
kotlinx.coroutines.CoroutineExceptionHandler { _, throwable ->
|
||||
if (isSqliteBusy(throwable)) {
|
||||
if (!DatabaseLockGate.onSqliteBusy(throwable)) {
|
||||
Logger.w("Desktop", "Suppressed SQLITE_BUSY in bootstrap coroutine", throwable)
|
||||
}
|
||||
} else {
|
||||
Logger.e("Desktop", "uncaught in bootstrap coroutine", throwable)
|
||||
}
|
||||
},
|
||||
)
|
||||
private var started = false
|
||||
|
||||
fun launchOnApplicationStart() {
|
||||
@@ -261,6 +273,16 @@ private object DesktopProtocolRegistration {
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
if (isSqliteBusy(throwable)) {
|
||||
if (!DatabaseLockGate.onSqliteBusy(throwable)) {
|
||||
Logger.w("Desktop", "Suppressed SQLITE_BUSY on ${thread.name}", throwable)
|
||||
}
|
||||
} else {
|
||||
Logger.e("Desktop", "uncaught on ${thread.name}", throwable)
|
||||
}
|
||||
}
|
||||
|
||||
configureDesktopAppDataName()
|
||||
// Do NOT set compose.layers.type=WINDOW.
|
||||
// On macOS Metal that mode creates a JWindow + MetalRedrawer per Popup/Dialog; native
|
||||
@@ -309,19 +331,12 @@ fun main(args: Array<String>) {
|
||||
// Re-apply after AWT is fully up — Taskbar can ignore early sets on macOS :run.
|
||||
SwingUtilities.invokeLater { applyDockIcon(dockIconImage) }
|
||||
|
||||
MessageDatabaseConcurrency.onSqliteBusy = { DatabaseLockGate.onSqliteBusy(it) }
|
||||
Runtime.getRuntime().addShutdownHook(Thread { DatabaseLockGate.releaseRuntimeLock() })
|
||||
MessageDatabaseRuntimeLock.tryAcquire()
|
||||
|
||||
application {
|
||||
UtilsLibrary.init()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
if (isSqliteBusy(throwable)) {
|
||||
Logger.w(
|
||||
"Desktop",
|
||||
"SQLite busy on ${thread.name}; database will retry on the next access",
|
||||
throwable,
|
||||
)
|
||||
} else {
|
||||
Logger.e("Desktop", "uncaught on ${thread.name}", throwable)
|
||||
}
|
||||
}
|
||||
var bootstrapStarted by remember { mutableStateOf(false) }
|
||||
val databaseBlocked by DatabaseLockGate.blocked.collectAsState()
|
||||
val lockingProcesses by DatabaseLockGate.processes.collectAsState()
|
||||
@@ -335,12 +350,25 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
val mac = remember { isMacOs() }
|
||||
val windows = remember { isWindowsOs() }
|
||||
val windowIcon = remember {
|
||||
dockIconImage?.toPainter() ?: loadAppIconPainter(tray = false)
|
||||
}
|
||||
val appName = stringResource(
|
||||
if (AppBuildInfo.isDebug) Res.string.app_name_beta else Res.string.app_name,
|
||||
)
|
||||
|
||||
if (databaseBlocked) {
|
||||
DatabaseLockWindow(
|
||||
appName = appName,
|
||||
windowIcon = windowIcon,
|
||||
dockIconImage = dockIconImage,
|
||||
processes = lockingProcesses,
|
||||
onKillProcess = { DatabaseLockGate.killProcess(it) },
|
||||
onQuit = { exitApplication() },
|
||||
)
|
||||
return@application
|
||||
}
|
||||
|
||||
val windowState = rememberWindowState(
|
||||
@@ -351,20 +379,12 @@ fun main(args: Array<String>) {
|
||||
var contentReady by remember { mutableStateOf(false) }
|
||||
var windowVisible by remember { mutableStateOf(true) }
|
||||
val trayState = rememberTrayState()
|
||||
val mac = remember { isMacOs() }
|
||||
val windows = remember { isWindowsOs() }
|
||||
val useTemplateTrayIcon = mac
|
||||
val trayIconImage = remember { loadAppIconBufferedImage(tray = useTemplateTrayIcon) }
|
||||
val trayIcon = remember(trayIconImage) {
|
||||
trayIconImage?.toPainter() ?: loadAppIconPainter(tray = useTemplateTrayIcon)
|
||||
}
|
||||
val windowIcon = remember {
|
||||
dockIconImage?.toPainter() ?: loadAppIconPainter(tray = false)
|
||||
}
|
||||
val traySupported = remember { java.awt.SystemTray.isSupported() }
|
||||
val appName = stringResource(
|
||||
if (AppBuildInfo.isDebug) Res.string.app_name_beta else Res.string.app_name,
|
||||
)
|
||||
val aboutApp = stringResource(Res.string.desktop_about_app)
|
||||
val trayShow = stringResource(Res.string.desktop_tray_show)
|
||||
val quit = stringResource(Res.string.desktop_quit)
|
||||
@@ -564,7 +584,7 @@ fun main(args: Array<String>) {
|
||||
onCloseRequest = { requestCloseToBackgroundOrQuit() },
|
||||
title = appName,
|
||||
state = windowState,
|
||||
visible = contentReady && (windowVisible || !traySupported) && !databaseBlocked,
|
||||
visible = contentReady && (windowVisible || !traySupported),
|
||||
icon = windowIcon,
|
||||
undecorated = windows,
|
||||
onPreviewKeyEvent = { event ->
|
||||
@@ -632,7 +652,7 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
|
||||
DisposableEffect(window) {
|
||||
installLoggedWindowExceptionHandler(window)
|
||||
installDesktopWindowExceptionHandler(window)
|
||||
AppForeground.setForeground(true)
|
||||
fun syncWindowFocus(focused: Boolean) {
|
||||
DesktopAppVisibility.isWindowFocused = focused
|
||||
@@ -714,9 +734,7 @@ fun main(args: Array<String>) {
|
||||
.fillMaxSize()
|
||||
.background(windowChrome),
|
||||
) {
|
||||
if (!databaseBlocked) {
|
||||
App(onContentReady = { contentReady = true })
|
||||
}
|
||||
App(onContentReady = { contentReady = true })
|
||||
if (windows) {
|
||||
MaterialTheme(colorScheme = getColorScheme(desktopAppDarkTheme(), dynamicColor = false)) {
|
||||
WindowsDesktopTitleBar(
|
||||
@@ -868,15 +886,12 @@ private fun applyDesktopWindowChromeBackground() {
|
||||
UIManager.put("control", ColorUIResource(desktopThemeBackgroundCompose().toAwtColor()))
|
||||
}
|
||||
|
||||
private fun desktopThemeBackgroundCompose() =
|
||||
if (desktopAppDarkTheme()) Color(0xFF1C1B1F) else Color(0xFFFFFBFE)
|
||||
|
||||
private fun Color.toAwtColor() =
|
||||
java.awt.Color(red, green, blue, alpha)
|
||||
|
||||
private fun isSystemAppearanceDark() = desktopSystemDarkTheme()
|
||||
|
||||
private fun applyDockIcon(image: BufferedImage?) {
|
||||
internal fun applyDockIcon(image: BufferedImage?) {
|
||||
if (image == null) {
|
||||
Logger.w("DesktopIcon", "applyDockIcon skipped — image is null")
|
||||
return
|
||||
@@ -1091,13 +1106,6 @@ private fun scaleBufferedImage(source: BufferedImage, size: Int): BufferedImage
|
||||
return out
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
private fun installLoggedWindowExceptionHandler(window: androidx.compose.ui.awt.ComposeWindow) {
|
||||
window.exceptionHandler = WindowExceptionHandler { throwable ->
|
||||
Logger.e("DesktopWindow", "uncaught in composition", throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque mark so Tray never substitutes the Compose default for a blank painter. */
|
||||
private fun solidFallbackIcon(): BufferedImage {
|
||||
val out = BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import ru.fromchat.api.local.db.store.MessageDatabasePaths
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.channels.FileLock
|
||||
|
||||
/**
|
||||
* Holds an exclusive lock on a sidecar file for the lifetime of this process so only one
|
||||
* desktop instance can use the message database at a time.
|
||||
*/
|
||||
internal object MessageDatabaseRuntimeLock {
|
||||
private var lockHolder: RandomAccessFile? = null
|
||||
private var fileLock: FileLock? = null
|
||||
|
||||
fun isHeld(): Boolean = fileLock?.isValid == true
|
||||
|
||||
fun tryAcquire(): Boolean {
|
||||
if (isHeld()) return true
|
||||
val lockFile = instanceLockFile()
|
||||
lockFile.parentFile?.mkdirs()
|
||||
return runCatching {
|
||||
val raf = RandomAccessFile(lockFile, "rw")
|
||||
val lock = raf.channel.tryLock()
|
||||
if (lock == null) {
|
||||
raf.close()
|
||||
false
|
||||
} else {
|
||||
lockHolder = raf
|
||||
fileLock = lock
|
||||
true
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
runCatching { fileLock?.release() }
|
||||
runCatching { lockHolder?.close() }
|
||||
fileLock = null
|
||||
lockHolder = null
|
||||
}
|
||||
|
||||
private fun instanceLockFile(): File {
|
||||
val dbDir = MessageDatabasePaths.databaseFile().parentFile
|
||||
return File(dbDir, "message_database.instance.lock")
|
||||
}
|
||||
}
|
||||
@@ -52,32 +52,6 @@ internal object ProcessExecutableResolver {
|
||||
return SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
|
||||
}
|
||||
|
||||
fun findFromChatProcesses(): List<LockingProcessInfo> {
|
||||
val currentPid = ProcessHandle.current().pid()
|
||||
return ProcessHandle.allProcesses().toList().mapNotNull { handle ->
|
||||
val pid = handle.pid()
|
||||
if (pid == currentPid) return@mapNotNull null
|
||||
val command = handle.info().command().orElse("")
|
||||
if (!looksLikeFromChatProcess(command)) return@mapNotNull null
|
||||
val executable = extractExecutablePath(command)
|
||||
LockingProcessInfo(
|
||||
pid = pid,
|
||||
name = handle.info().command().map { File(it).name }.orElse("FromChat"),
|
||||
description = command,
|
||||
executablePath = executable,
|
||||
icon = iconForExecutable(executable),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun looksLikeFromChatProcess(command: String): Boolean {
|
||||
if (command.isBlank()) return false
|
||||
val lower = command.lowercase()
|
||||
return lower.contains("ru.fromchat.desktop.mainkt") ||
|
||||
lower.contains("fromchat.desktop.main") ||
|
||||
(lower.contains("fromchat") && (lower.contains("java") || lower.contains("javaw")))
|
||||
}
|
||||
|
||||
private fun extractExecutablePath(command: String): String? {
|
||||
val trimmed = command.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
@@ -93,6 +93,8 @@ internal fun Window.windowsHwnd(): HWND {
|
||||
internal fun HWND.isNativeZoomed(): Boolean =
|
||||
User32.INSTANCE.GetWindowLong(this, WinUser.GWL_STYLE) and WS_MAXIMIZE != 0
|
||||
|
||||
internal fun Window.isNativeZoomed(): Boolean = windowsHwnd().isNativeZoomed()
|
||||
|
||||
private const val WS_MAXIMIZE = 0x01000000
|
||||
|
||||
@Suppress("FunctionName")
|
||||
|
||||
@@ -2,14 +2,15 @@ package ru.fromchat.desktop
|
||||
|
||||
import com.sun.jna.Native
|
||||
import com.sun.jna.Structure
|
||||
import com.sun.jna.WString
|
||||
import com.sun.jna.platform.win32.WinBase
|
||||
import ru.fromchat.Logger
|
||||
import com.sun.jna.platform.win32.WinDef.DWORD
|
||||
import com.sun.jna.platform.win32.WinNT
|
||||
import com.sun.jna.ptr.IntByReference
|
||||
import com.sun.jna.win32.StdCallLibrary
|
||||
import com.sun.jna.win32.W32APIOptions
|
||||
import java.io.File
|
||||
import java.lang.ProcessHandle
|
||||
|
||||
internal object WindowsRestartManager {
|
||||
private const val ERROR_MORE_DATA = 234
|
||||
@@ -18,26 +19,36 @@ internal object WindowsRestartManager {
|
||||
|
||||
fun findLockingProcesses(files: List<File>): List<LockingProcessInfo> {
|
||||
if (!isWindowsOs()) return emptyList()
|
||||
val paths = files.map { it.absolutePath }.filter { it.isNotBlank() }
|
||||
val paths = files
|
||||
.map { file -> runCatching { file.canonicalPath }.getOrElse { file.absolutePath } }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
if (paths.isEmpty()) return emptyList()
|
||||
|
||||
val session = IntByReference()
|
||||
val sessionKey = CharArray(33)
|
||||
val start = RestartManager.INSTANCE.RmStartSession(session, 0, sessionKey)
|
||||
if (start != WinNT.ERROR_SUCCESS) return emptyList()
|
||||
if (start != WinNT.ERROR_SUCCESS) {
|
||||
Logger.w("WindowsRestartManager", "RmStartSession failed: $start")
|
||||
return emptyList()
|
||||
}
|
||||
val sessionHandle = session.value
|
||||
|
||||
try {
|
||||
val pathArray = paths.toTypedArray()
|
||||
val register = RestartManager.INSTANCE.RmRegisterResources(
|
||||
sessionHandle,
|
||||
paths.size,
|
||||
paths.toTypedArray(),
|
||||
pathArray.size,
|
||||
pathArray,
|
||||
0,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
)
|
||||
if (register != WinNT.ERROR_SUCCESS) return emptyList()
|
||||
if (register != WinNT.ERROR_SUCCESS) {
|
||||
Logger.w("WindowsRestartManager", "RmRegisterResources failed: $register paths=$paths")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val needed = IntByReference()
|
||||
val count = IntByReference()
|
||||
@@ -48,10 +59,16 @@ internal object WindowsRestartManager {
|
||||
null,
|
||||
null,
|
||||
)
|
||||
if (result != ERROR_MORE_DATA && result != WinNT.ERROR_SUCCESS) return emptyList()
|
||||
if (result != ERROR_MORE_DATA && result != WinNT.ERROR_SUCCESS) {
|
||||
Logger.w("WindowsRestartManager", "RmGetList probe failed: $result")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val processCount = needed.value.coerceAtLeast(count.value).coerceAtLeast(1)
|
||||
val processes = Array(processCount) { RmProcessInfo() }
|
||||
val processCount = needed.value.coerceAtLeast(count.value)
|
||||
if (processCount <= 0) return emptyList()
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val processes = RmProcessInfo().toArray(processCount) as Array<RmProcessInfo>
|
||||
count.value = processCount
|
||||
result = RestartManager.INSTANCE.RmGetList(
|
||||
sessionHandle,
|
||||
@@ -60,13 +77,18 @@ internal object WindowsRestartManager {
|
||||
processes,
|
||||
null,
|
||||
)
|
||||
if (result != WinNT.ERROR_SUCCESS) return emptyList()
|
||||
if (result != WinNT.ERROR_SUCCESS) {
|
||||
Logger.w("WindowsRestartManager", "RmGetList failed: $result")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val currentPid = ProcessHandle.current().pid()
|
||||
return processes
|
||||
.take(count.value.coerceAtMost(processes.size))
|
||||
.onEach { it.read() }
|
||||
.mapNotNull { info ->
|
||||
val pid = info.dwProcessId.toLong()
|
||||
if (pid <= 0L) return@mapNotNull null
|
||||
if (pid <= 0L || pid == currentPid) return@mapNotNull null
|
||||
val appName = info.appName().trim()
|
||||
val executable = ProcessExecutableResolver.executablePathForPid(pid)
|
||||
LockingProcessInfo(
|
||||
@@ -93,7 +115,7 @@ internal object WindowsRestartManager {
|
||||
"tsSessionId",
|
||||
"restartable",
|
||||
)
|
||||
private class RmProcessInfo : Structure(), Structure.ByReference {
|
||||
private class RmProcessInfo : Structure() {
|
||||
@JvmField var dwProcessId = 0
|
||||
@JvmField var processStartTime = WinBase.FILETIME()
|
||||
@JvmField var strAppName = CharArray(CCH_RM_MAX_APP_NAME + 1)
|
||||
@@ -114,7 +136,7 @@ internal object WindowsRestartManager {
|
||||
fileCount: Int,
|
||||
fileNames: Array<String>?,
|
||||
serviceCount: Int,
|
||||
serviceNames: Array<WString>?,
|
||||
serviceNames: Array<String>?,
|
||||
processCount: Int,
|
||||
processIds: IntArray?,
|
||||
): Int
|
||||
|
||||
@@ -42,15 +42,6 @@ internal fun clampFloatingAwtWindowToWorkArea(window: Window): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun applyFloatingGeometry(
|
||||
window: Window,
|
||||
windowState: WindowState,
|
||||
geometry: DesktopFloatingGeometry,
|
||||
density: Density,
|
||||
) {
|
||||
applyFloatingGeometry(window, windowState, geometry.size, geometry.position, density)
|
||||
}
|
||||
|
||||
internal fun applyFloatingGeometry(
|
||||
window: Window,
|
||||
windowState: WindowState,
|
||||
@@ -80,7 +71,13 @@ internal fun applySavedFloatingGeometry(
|
||||
windowState: WindowState,
|
||||
density: Density,
|
||||
) {
|
||||
applyFloatingGeometry(window, windowState, DesktopWindowPrefs.loadFloatingGeometry(), density)
|
||||
applyFloatingGeometry(
|
||||
window,
|
||||
windowState,
|
||||
DesktopWindowPrefs.loadSize(),
|
||||
DesktopWindowPrefs.loadPosition(),
|
||||
density,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun captureFloatingGeometryToPrefs(
|
||||
@@ -91,7 +88,7 @@ internal fun captureFloatingGeometryToPrefs(
|
||||
if (windowState.placement != WindowPlacement.Floating || window.isNativeZoomed()) return
|
||||
val bounds = window.bounds
|
||||
with(density) {
|
||||
DesktopWindowPrefs.saveFloatingGeometry(
|
||||
DesktopWindowPrefs.save(
|
||||
size = DpSize(bounds.width.toDp(), bounds.height.toDp()),
|
||||
position = WindowPosition(bounds.x.toDp(), bounds.y.toDp()),
|
||||
)
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.OutputDirectory
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
|
||||
@@ -26,6 +28,9 @@ abstract class GenerateAppBuildInfoTask : DefaultTask() {
|
||||
@get:Input
|
||||
abstract val debugBuild: Property<Boolean>
|
||||
|
||||
@get:InputFile
|
||||
abstract val releaseNotesFile: RegularFileProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outputDirectory: DirectoryProperty
|
||||
|
||||
@@ -35,6 +40,14 @@ abstract class GenerateAppBuildInfoTask : DefaultTask() {
|
||||
check(outRoot.invariantSeparatorsPath.contains("/generated/")) {
|
||||
"AppBuildInfo must be written under a generated/ directory, got: $outRoot"
|
||||
}
|
||||
val releaseNotesEscaped = releaseNotesFile.get().asFile
|
||||
.takeIf { it.isFile }
|
||||
?.readText()
|
||||
?.replace("\\", "\\\\")
|
||||
?.replace("\"", "\\\"")
|
||||
?.replace("\r\n", "\n")
|
||||
?.replace("\n", "\\n")
|
||||
?: ""
|
||||
outRoot.deleteRecursively()
|
||||
outRoot.resolve("ru/fromchat").apply { mkdirs() }.resolve("AppBuildInfo.kt").writeText(
|
||||
"""
|
||||
@@ -45,6 +58,7 @@ abstract class GenerateAppBuildInfoTask : DefaultTask() {
|
||||
| const val version = "${versionName.get()}"
|
||||
| const val versionCode = ${versionCode.get()}
|
||||
| const val isDebug = ${debugBuild.get()}
|
||||
| const val releaseNotesMarkdown = "$releaseNotesEscaped"
|
||||
|}
|
||||
|
|
||||
""".trimMargin()
|
||||
@@ -55,6 +69,7 @@ abstract class GenerateAppBuildInfoTask : DefaultTask() {
|
||||
val generateAppBuildInfo = tasks.register<GenerateAppBuildInfoTask>("generateAppBuildInfo") {
|
||||
versionName.set(rootProject.extra["versionName"] as String)
|
||||
versionCode.set(rootProject.extra["versionCode"] as Int)
|
||||
releaseNotesFile.set(rootProject.layout.projectDirectory.file("RELEASE_NOTES.md"))
|
||||
debugBuild.set(
|
||||
gradle.startParameter.taskNames.let { names ->
|
||||
!names.any { it.contains("Release", ignoreCase = true) } ||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
internal actual val messageDatabaseDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
@@ -12,6 +12,10 @@
|
||||
<string name="about_link_website">Сайт</string>
|
||||
<string name="about_link_privacy">Политика конфиденциальности</string>
|
||||
<string name="about_link_terms">Пользовательское соглашение</string>
|
||||
<string name="about_what_changed">Что изменилось в этом обновлении</string>
|
||||
<string name="release_notes_title">Что нового</string>
|
||||
<string name="release_notes_ok">OK</string>
|
||||
<string name="release_notes_dont_show_again">Больше не показывать</string>
|
||||
<string name="legal_privacy_title">Политика конфиденциальности</string>
|
||||
<string name="legal_terms_title">Пользовательское соглашение</string>
|
||||
<string name="legal_document_cached_banner">Показана сохранённая копия документа. Содержимое может быть устаревшим.</string>
|
||||
@@ -38,6 +42,7 @@
|
||||
<string name="desktop_database_locked_kill">Завершить процесс</string>
|
||||
<string name="desktop_database_locked_waiting">Ожидание освобождения базы данных…</string>
|
||||
<string name="desktop_database_locked_unknown_process">Неизвестный процесс</string>
|
||||
<string name="desktop_database_locked_unknown_hint">Не удалось определить процесс. Возможно, базу удерживает другое приложение (IDE, просмотрщик БД, антивирус или синхронизация папки с данными). Проверьте диспетчер задач на скрытый java.exe.</string>
|
||||
<string name="desktop_database_locked_pid">PID %1$d</string>
|
||||
<string name="welcome">Добро пожаловать!</string>
|
||||
<string name="login">Войти</string>
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<string name="about_link_website">Website</string>
|
||||
<string name="about_link_privacy">Privacy policy</string>
|
||||
<string name="about_link_terms">Terms of service</string>
|
||||
<string name="about_what_changed">What changed in this update</string>
|
||||
<string name="release_notes_title">What\'s new</string>
|
||||
<string name="release_notes_ok">OK</string>
|
||||
<string name="release_notes_dont_show_again">Don\'t show again</string>
|
||||
<string name="legal_privacy_title">Privacy policy</string>
|
||||
<string name="legal_terms_title">Terms of service</string>
|
||||
<string name="legal_document_cached_banner">Showing a saved copy. Content may be out of date.</string>
|
||||
@@ -43,6 +47,7 @@
|
||||
<string name="desktop_database_locked_kill">End process</string>
|
||||
<string name="desktop_database_locked_waiting">Waiting for the database to become available…</string>
|
||||
<string name="desktop_database_locked_unknown_process">Unknown process</string>
|
||||
<string name="desktop_database_locked_unknown_hint">Could not identify the locking process. Another app may have the database open (IDE, DB browser, antivirus, or cloud sync on the data folder). Check Task Manager for a hidden java.exe.</string>
|
||||
<string name="desktop_database_locked_pid">PID %1$d</string>
|
||||
|
||||
<!-- Authentication -->
|
||||
|
||||
+9
-1
@@ -6,14 +6,17 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.ChatListSync
|
||||
import ru.fromchat.api.ProfileUpdateSync
|
||||
import ru.fromchat.api.PublicChatProfileSync
|
||||
import ru.fromchat.api.StatusSubscriptionCoordinator
|
||||
import ru.fromchat.api.local.db.store.InstanceRegistryStore
|
||||
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
|
||||
import ru.fromchat.api.local.db.store.MessageRepository
|
||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||
import ru.fromchat.api.local.db.store.messageDatabaseDispatcher
|
||||
import ru.fromchat.api.local.download.AttachmentDownloadNotifier
|
||||
import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
||||
import ru.fromchat.config.Settings
|
||||
@@ -39,7 +42,12 @@ private fun scheduleAttachmentResumeAfterSession() {
|
||||
|
||||
private suspend fun activateInstance(instanceId: String) {
|
||||
CacheContext.setActiveInstance(instanceId, ApiClient.user?.id)
|
||||
runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) }
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) }
|
||||
if (instanceId.isNotBlank()) {
|
||||
MessageDatabaseProvider.rebindUnboundPartition(instanceId)
|
||||
}
|
||||
}
|
||||
PublicChatProfileSync.ensureStarted()
|
||||
ProfileUpdateSync.ensureStarted()
|
||||
StatusSubscriptionCoordinator.ensureStarted()
|
||||
|
||||
@@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
|
||||
import ru.fromchat.api.local.cache.CacheContext.activeInstanceId
|
||||
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
|
||||
import ru.fromchat.ui.chat.panels.dm.DmPanelCache
|
||||
@@ -28,12 +27,8 @@ object CacheContext {
|
||||
_activeUserId.value = userId
|
||||
if (changed) {
|
||||
ProfileCache.onActiveInstanceChanged(trimmed)
|
||||
runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(trimmed) }
|
||||
PublicChatPanelCache.onActiveInstanceChanged(trimmed)
|
||||
DmPanelCache.onActiveInstanceChanged(trimmed)
|
||||
if (trimmed.isNotEmpty()) {
|
||||
MessageDatabaseProvider.rebindUnboundPartition(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package ru.fromchat.api.local.db
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.fromchat.api.local.db.store.messageDatabaseDispatcher
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.ChatListSync
|
||||
import ru.fromchat.api.ProfileUpdateSync
|
||||
@@ -36,7 +36,7 @@ suspend fun wipeLocalCacheOnDisk() {
|
||||
if (instanceId.isNotEmpty()) {
|
||||
cancelOutboxProcessing(instanceId)
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
MessageDatabaseProvider.closeAndReset()
|
||||
}
|
||||
wipeFromChatCacheDirectory()
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package ru.fromchat.api.local.db
|
||||
|
||||
object MessageDatabaseConcurrency {
|
||||
var onSqliteBusy: ((Throwable) -> Unit)? = null
|
||||
|
||||
fun notifySqliteBusy(throwable: Throwable) {
|
||||
onSqliteBusy?.invoke(throwable)
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -270,7 +270,9 @@ private fun normalizeLegacyConversationIds(driver: SqlDriver) {
|
||||
}
|
||||
|
||||
private fun migrationInstanceId(): String {
|
||||
val known = runBlocking { Settings.lastKnownServerInstanceId.trim() }
|
||||
// Do not read via Settings.lastKnownServerInstanceId here — its getter nests runBlocking and
|
||||
// deadlocks when this runs on the single-thread SQLite executor while the UI waits on .get().
|
||||
val known = runBlocking { Settings.readLastKnownServerInstanceIdRaw().trim() }
|
||||
if (known.isNotEmpty() && isValidInstanceUuid(known)) return known
|
||||
return UNBOUND_MIGRATION_INSTANCE_ID
|
||||
}
|
||||
|
||||
@@ -14,25 +14,14 @@ fun isSqliteBusy(throwable: Throwable): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
internal fun <T> withSqliteBusyRetry(
|
||||
maxAttempts: Int = 8,
|
||||
initialDelayMs: Long = 25L,
|
||||
block: () -> T,
|
||||
): T {
|
||||
var attempt = 0
|
||||
var delayMs = initialDelayMs
|
||||
while (true) {
|
||||
try {
|
||||
return block()
|
||||
} catch (e: Throwable) {
|
||||
attempt++
|
||||
if (!isSqliteBusy(e) || attempt >= maxAttempts) throw e
|
||||
Logger.w(
|
||||
"MessageDatabase",
|
||||
"SQLite busy (attempt $attempt/$maxAttempts), retrying in ${delayMs}ms",
|
||||
)
|
||||
Thread.sleep(delayMs)
|
||||
delayMs = minOf(delayMs * 2, 500L)
|
||||
internal fun <T> withSqliteBusyGuard(block: () -> T): T {
|
||||
try {
|
||||
return block()
|
||||
} catch (e: Throwable) {
|
||||
if (isSqliteBusy(e)) {
|
||||
Logger.w("MessageDatabase", "SQLite busy", e)
|
||||
MessageDatabaseConcurrency.notifySqliteBusy(e)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
+6
-7
@@ -1,6 +1,5 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.fromchat.api.local.send.cancelOutboxProcessing
|
||||
import ru.fromchat.config.ServerConfigData
|
||||
@@ -18,7 +17,7 @@ object InstanceRegistryStore {
|
||||
private fun nowIso(): String = Clock.System.now().toString()
|
||||
|
||||
suspend fun getActiveInstanceIdForConfig(config: ServerConfigData): String? =
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
MessageDatabaseProvider.withDatabaseRecover {
|
||||
db.messageDatabaseQueries
|
||||
.selectActiveInstanceIdForConfig(config.configKey())
|
||||
@@ -30,7 +29,7 @@ object InstanceRegistryStore {
|
||||
val id = instanceId.trim()
|
||||
if (id.isEmpty()) return
|
||||
val now = nowIso()
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
MessageDatabaseProvider.withDatabaseRecover {
|
||||
val existing = db.messageDatabaseQueries
|
||||
.selectAllInstanceIds()
|
||||
@@ -54,7 +53,7 @@ object InstanceRegistryStore {
|
||||
}
|
||||
val key = config.configKey()
|
||||
val now = nowIso()
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
MessageDatabaseProvider.withDatabaseRecover {
|
||||
db.messageDatabaseQueries.upsertServerBinding(key, newId, now)
|
||||
val existing = db.messageDatabaseQueries
|
||||
@@ -93,7 +92,7 @@ object InstanceRegistryStore {
|
||||
suspend fun purgePartition(instanceId: String) {
|
||||
val id = instanceId.trim()
|
||||
if (id.isEmpty()) return
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
MessageDatabaseProvider.withDatabaseRecover {
|
||||
db.messageDatabaseQueries.deleteAllMessagesForInstance(id)
|
||||
db.messageDatabaseQueries.deleteAllConversationsForInstance(id)
|
||||
@@ -106,7 +105,7 @@ object InstanceRegistryStore {
|
||||
}
|
||||
|
||||
suspend fun purgeAllCache() {
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
MessageDatabaseProvider.withDatabaseRecover {
|
||||
db.messageDatabaseQueries.purgeAllCache()
|
||||
}
|
||||
@@ -114,7 +113,7 @@ object InstanceRegistryStore {
|
||||
}
|
||||
|
||||
suspend fun clearServerBindingForCurrentConfig() {
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
MessageDatabaseProvider.withDatabaseRecover {
|
||||
db.messageDatabaseQueries.deleteServerBinding(Settings.serverConfig.configKey())
|
||||
}
|
||||
|
||||
+49
-49
@@ -87,9 +87,9 @@ object MessageCacheStore {
|
||||
db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(instanceId, conversationId)
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
.mapToList(messageDatabaseDispatcher)
|
||||
.mapLatest { rows ->
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
val raw = hydrateReplyReferencesFromRows(rows)
|
||||
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
||||
val hydrated = hydrateAttachmentPreviewsFromDisk(withoutSuperseded)
|
||||
@@ -155,7 +155,7 @@ object MessageCacheStore {
|
||||
suspend fun loadRecentPublicChatPreviewState(
|
||||
strings: ChatListPreviewStrings,
|
||||
limit: Long = 1,
|
||||
): ChatListPreviewState? = withContext(Dispatchers.Default) {
|
||||
): ChatListPreviewState? = withContext(messageDatabaseDispatcher) {
|
||||
val convId = conversationIdForPublic()
|
||||
val iid = instanceId()
|
||||
val recent = resolvePreviewSourceMessageRow(iid, convId)
|
||||
@@ -185,7 +185,7 @@ object MessageCacheStore {
|
||||
dropSupersededOptimisticMessages(before, ApiClient.user?.id),
|
||||
).let { sortMessagesForChatDisplay(it) }
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
purgeSupersededPendingRows(iid, convId, before, merged)
|
||||
}
|
||||
replaceMessages(convId, merged, replaceAll = replaceAll)
|
||||
@@ -219,7 +219,7 @@ object MessageCacheStore {
|
||||
).let { sortMessagesForChatDisplay(it) }
|
||||
val hydrated = hydrateReplyToInMemory(merged)
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
purgeSupersededPendingRows(iid, convId, before, hydrated)
|
||||
}
|
||||
replaceMessages(convId, hydrated, replaceAll = replaceAll)
|
||||
@@ -273,7 +273,7 @@ object MessageCacheStore {
|
||||
val cid = clientMessageId.trim()
|
||||
if (cid.isEmpty()) return
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries.updateMessageSendStatusByClientMessageId(
|
||||
sendStatus = "failed",
|
||||
instanceId = iid,
|
||||
@@ -287,7 +287,7 @@ object MessageCacheStore {
|
||||
val cid = clientMessageId.trim()
|
||||
if (cid.isEmpty()) return
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries.updateMessageSendStatusByClientMessageId(
|
||||
sendStatus = "pending",
|
||||
instanceId = iid,
|
||||
@@ -337,7 +337,7 @@ object MessageCacheStore {
|
||||
}
|
||||
|
||||
suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) {
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
var resolved = confirmed.resolvePublicAttachmentLayout()
|
||||
resolved = hydrateAttachmentPreviewFromDisk(resolved)
|
||||
ProfileCache.mergePreviewFromPublicMessage(resolved)
|
||||
@@ -413,7 +413,7 @@ object MessageCacheStore {
|
||||
if (messageId <= 0 || !DecryptedImageCache.isDecryptedImageCacheUri(localPreviewUri)) return
|
||||
val convId = conversationIdForPublic()
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
val row = db.messageDatabaseQueries
|
||||
.selectMessageById(iid, convId, messageId.toLong())
|
||||
.executeAsOneOrNull() ?: return@withContext
|
||||
@@ -446,7 +446,7 @@ object MessageCacheStore {
|
||||
if (messageId <= 0 || !DecryptedImageCache.isDecryptedImageCacheUri(localPreviewUri)) return
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
val row = db.messageDatabaseQueries
|
||||
.selectMessageById(iid, convId, messageId.toLong())
|
||||
.executeAsOneOrNull() ?: return@withContext
|
||||
@@ -475,7 +475,7 @@ object MessageCacheStore {
|
||||
"MessageCache",
|
||||
"markMessageDeleted (soft) convId=$conversationId messageId=$messageId",
|
||||
)
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries.markMessageDeleted(
|
||||
instanceId = iid,
|
||||
id = messageId.toLong(),
|
||||
@@ -491,7 +491,7 @@ object MessageCacheStore {
|
||||
previewStrings?.let { listPreviewStrings = it }
|
||||
val iid = instanceId()
|
||||
val currentUserId = ApiClient.user?.id
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
val upserts = conversations.map { conv ->
|
||||
val conversationId = conversationIdForDm(conv.user.id)
|
||||
val displayLabel = when {
|
||||
@@ -583,7 +583,7 @@ object MessageCacheStore {
|
||||
|
||||
suspend fun pruneEmptyConversations() {
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries.transaction {
|
||||
pruneEmptyConversationsLocked(iid)
|
||||
}
|
||||
@@ -597,7 +597,7 @@ object MessageCacheStore {
|
||||
suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
val existing = db.messageDatabaseQueries
|
||||
.selectConversationById(iid, convId)
|
||||
.executeAsOneOrNull()
|
||||
@@ -644,7 +644,7 @@ object MessageCacheStore {
|
||||
if (otherUserId <= 0) return
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
val existing = db.messageDatabaseQueries
|
||||
.selectConversationById(iid, convId)
|
||||
.executeAsOneOrNull() ?: run {
|
||||
@@ -703,7 +703,7 @@ object MessageCacheStore {
|
||||
suspend fun markDmConversationReadLocally(otherUserId: Int, messageIds: List<Int>? = null) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
if (messageIds.isNullOrEmpty()) {
|
||||
db.messageDatabaseQueries.markAllInboundDmMessagesRead(
|
||||
instanceId = iid,
|
||||
@@ -730,7 +730,7 @@ object MessageCacheStore {
|
||||
suspend fun selectUnreadPublicMessageIds(): List<Int> {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForPublic()
|
||||
return withContext(Dispatchers.Default) {
|
||||
return withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries
|
||||
.selectUnreadPublicMessageIds(iid, convId)
|
||||
.executeAsList()
|
||||
@@ -742,7 +742,7 @@ object MessageCacheStore {
|
||||
if (messageId <= 0) return true
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForPublic()
|
||||
return withContext(Dispatchers.Default) {
|
||||
return withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries
|
||||
.selectMessageById(iid, convId, messageId.toLong())
|
||||
.executeAsOneOrNull()
|
||||
@@ -753,7 +753,7 @@ object MessageCacheStore {
|
||||
suspend fun markPublicMessagesReadLocally(messageIds: List<Int>? = null) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForPublic()
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
if (messageIds == null) {
|
||||
db.messageDatabaseQueries.markPublicMessagesRead(iid, convId)
|
||||
} else {
|
||||
@@ -767,7 +767,7 @@ object MessageCacheStore {
|
||||
suspend fun archiveDmConversation(otherUserId: Int) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries.updateConversationArchived(
|
||||
archived = 1L,
|
||||
instanceId = iid,
|
||||
@@ -780,7 +780,7 @@ object MessageCacheStore {
|
||||
suspend fun deleteDmConversation(otherUserId: Int) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
val messages = db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(iid, convId)
|
||||
.executeAsList()
|
||||
@@ -806,7 +806,7 @@ object MessageCacheStore {
|
||||
|
||||
suspend fun purgePendingNotFromUser(userId: Int) {
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
val foreign = db.messageDatabaseQueries
|
||||
.selectForeignPendingMessages(iid, userId.toLong())
|
||||
.executeAsList()
|
||||
@@ -828,7 +828,7 @@ object MessageCacheStore {
|
||||
suspend fun purgeAllPendingForInstance() {
|
||||
val iid = runCatching { instanceId() }.getOrNull()?.trim().orEmpty()
|
||||
if (iid.isEmpty()) return
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
val pending = db.messageDatabaseQueries
|
||||
.selectAllPendingMessagesForInstance(iid)
|
||||
.executeAsList()
|
||||
@@ -856,7 +856,7 @@ object MessageCacheStore {
|
||||
}
|
||||
|
||||
suspend fun loadCachedDmConversations(): List<CachedConversation> =
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
loadCachedDmConversationsRows(instanceId())
|
||||
}
|
||||
|
||||
@@ -893,19 +893,19 @@ object MessageCacheStore {
|
||||
val conversationsFlow = db.messageDatabaseQueries
|
||||
.selectActiveDmConversationsForInstance(instanceId)
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
.mapToList(messageDatabaseDispatcher)
|
||||
val messagesFlow = db.messageDatabaseQueries
|
||||
.selectMessagesForInstance(instanceId)
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
.mapToList(messageDatabaseDispatcher)
|
||||
val pendingFlow = db.messageDatabaseQueries
|
||||
.selectAllPendingMessagesForInstance(instanceId)
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
.mapToList(messageDatabaseDispatcher)
|
||||
val outboxFlow = db.messageDatabaseQueries
|
||||
.selectPendingOutboxForInstance(instanceId)
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
.mapToList(messageDatabaseDispatcher)
|
||||
val notifierFlow = DmConversationListNotifier.events.map { Unit }
|
||||
return merge(conversationsFlow, messagesFlow, pendingFlow, outboxFlow, notifierFlow)
|
||||
.mapLatest { loadCachedDmConversations() }
|
||||
@@ -920,15 +920,15 @@ object MessageCacheStore {
|
||||
val messagesFlow = db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(instanceId, convId)
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
.mapToList(messageDatabaseDispatcher)
|
||||
val pendingFlow = db.messageDatabaseQueries
|
||||
.selectAllPendingMessagesForInstance(instanceId)
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
.mapToList(messageDatabaseDispatcher)
|
||||
val outboxFlow = db.messageDatabaseQueries
|
||||
.selectPendingOutboxForInstance(instanceId)
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
.mapToList(messageDatabaseDispatcher)
|
||||
return merge(messagesFlow, pendingFlow, outboxFlow)
|
||||
.mapLatest { loadRecentPublicChatPreviewState(strings) }
|
||||
}
|
||||
@@ -1007,7 +1007,7 @@ object MessageCacheStore {
|
||||
private suspend fun syncDmConversationPreviewFromCache(otherUserId: Int) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
var row = db.messageDatabaseQueries
|
||||
.selectConversationById(iid, convId)
|
||||
.executeAsOneOrNull()
|
||||
@@ -1057,7 +1057,7 @@ object MessageCacheStore {
|
||||
if (envelopeId <= 0) return true
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
return withContext(Dispatchers.Default) {
|
||||
return withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries
|
||||
.selectMessageById(iid, convId, envelopeId.toLong())
|
||||
.executeAsOneOrNull()
|
||||
@@ -1068,7 +1068,7 @@ object MessageCacheStore {
|
||||
private suspend fun clearConversationMessages(conversationId: String) {
|
||||
val iid = instanceId()
|
||||
Logger.d("MessageCache", "clearConversationMessages convId=$conversationId")
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
|
||||
}
|
||||
}
|
||||
@@ -1079,27 +1079,27 @@ object MessageCacheStore {
|
||||
"MessageCache",
|
||||
"deleteByClientMessageId convId=$conversationId clientId=$clientMessageId",
|
||||
)
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteMessageById(conversationId: String, messageId: Int) {
|
||||
val iid = instanceId()
|
||||
val beforeCount = withContext(Dispatchers.Default) {
|
||||
val beforeCount = withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(iid, conversationId)
|
||||
.executeAsList()
|
||||
.size
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries.deleteMessageById(
|
||||
instanceId = iid,
|
||||
conversationId = conversationId,
|
||||
id = messageId.toLong(),
|
||||
)
|
||||
}
|
||||
val afterCount = withContext(Dispatchers.Default) {
|
||||
val afterCount = withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(iid, conversationId)
|
||||
.executeAsList()
|
||||
@@ -1114,7 +1114,7 @@ object MessageCacheStore {
|
||||
|
||||
private suspend fun upsertSingle(conversationId: String, msg: Message) {
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
val existing = db.messageDatabaseQueries
|
||||
.selectMessageById(iid, conversationId, msg.id.toLong())
|
||||
.executeAsOneOrNull()
|
||||
@@ -1143,7 +1143,7 @@ object MessageCacheStore {
|
||||
!confirmed.files.isNullOrEmpty() -> encodePersistedPublicMessage(confirmed)
|
||||
else -> confirmed.content
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries.transaction {
|
||||
val existing = db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(iid, conversationId)
|
||||
@@ -1175,7 +1175,7 @@ object MessageCacheStore {
|
||||
private suspend fun loadMessages(conversationId: String): List<Message> {
|
||||
val iid = instanceId()
|
||||
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(iid)
|
||||
return withContext(Dispatchers.Default) {
|
||||
return withContext(messageDatabaseDispatcher) {
|
||||
val rows = db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(iid, conversationId)
|
||||
.executeAsList()
|
||||
@@ -1198,7 +1198,7 @@ object MessageCacheStore {
|
||||
|
||||
private suspend fun loadRecentMessages(conversationId: String, limit: Long): List<Message> {
|
||||
val iid = instanceId()
|
||||
return withContext(Dispatchers.Default) {
|
||||
return withContext(messageDatabaseDispatcher) {
|
||||
val rows = db.messageDatabaseQueries
|
||||
.selectRecentMessagesByConversation(iid, conversationId, limit)
|
||||
.executeAsList()
|
||||
@@ -1209,7 +1209,7 @@ object MessageCacheStore {
|
||||
|
||||
private suspend fun loadPendingMessages(conversationId: String): List<Message> {
|
||||
val iid = instanceId()
|
||||
return withContext(Dispatchers.Default) {
|
||||
return withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries
|
||||
.selectPendingMessagesByConversation(iid, conversationId)
|
||||
.executeAsList()
|
||||
@@ -1473,7 +1473,7 @@ object MessageCacheStore {
|
||||
|
||||
suspend fun clearAll() {
|
||||
Logger.d("MessageCache", "clearAll")
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries.purgeAllCache()
|
||||
}
|
||||
}
|
||||
@@ -1507,7 +1507,7 @@ object MessageCacheStore {
|
||||
}
|
||||
val validated = CacheValidator.filterMessages(conversationId, messages, self)
|
||||
val iid = instanceId()
|
||||
val beforeCount = withContext(Dispatchers.Default) {
|
||||
val beforeCount = withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(iid, conversationId)
|
||||
.executeAsList()
|
||||
@@ -1518,7 +1518,7 @@ object MessageCacheStore {
|
||||
"replaceMessages convId=$conversationId replaceAll=$replaceAll " +
|
||||
"incoming=${messages.size} validated=${validated.size} rowsBefore=$beforeCount",
|
||||
)
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
val existingRows = db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(iid, conversationId)
|
||||
.executeAsList()
|
||||
@@ -1556,7 +1556,7 @@ object MessageCacheStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
val afterCount = withContext(Dispatchers.Default) {
|
||||
val afterCount = withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(iid, conversationId)
|
||||
.executeAsList()
|
||||
@@ -1577,7 +1577,7 @@ object MessageCacheStore {
|
||||
val cid = clientMessageId.trim()
|
||||
if (cid.isEmpty()) return false
|
||||
val iid = instanceId()
|
||||
return withContext(Dispatchers.Default) {
|
||||
return withContext(messageDatabaseDispatcher) {
|
||||
db.messageDatabaseQueries
|
||||
.selectSentMessageIdByClientMessageId(iid, conversationId, cid)
|
||||
.executeAsOneOrNull() != null
|
||||
@@ -1590,7 +1590,7 @@ object MessageCacheStore {
|
||||
)
|
||||
|
||||
suspend fun findMessageForAttachmentStorageKey(storageKey: String): AttachmentResumeTarget? =
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
val key = storageKey.trim()
|
||||
if (key.isEmpty()) return@withContext null
|
||||
val iid = runCatching { instanceId() }.getOrNull() ?: return@withContext null
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
||||
/** Dispatcher for coroutines that read or write the message database. */
|
||||
internal expect val messageDatabaseDispatcher: CoroutineDispatcher
|
||||
|
||||
/** Public accessor for modules outside `:shared` (e.g. desktop bootstrap). */
|
||||
val messageDatabaseCoroutineDispatcher: CoroutineDispatcher
|
||||
get() = messageDatabaseDispatcher
|
||||
+13
-9
@@ -4,7 +4,8 @@ import app.cash.sqldelight.db.SqlDriver
|
||||
import ru.fromchat.api.local.db.ensureMessageDatabaseSchema
|
||||
import ru.fromchat.api.local.db.rebindUnboundMigrationInstance
|
||||
import ru.fromchat.api.local.db.withMessageDatabaseLock
|
||||
import ru.fromchat.api.local.db.withSqliteBusyRetry
|
||||
import ru.fromchat.api.local.db.isSqliteBusy
|
||||
import ru.fromchat.api.local.db.MessageDatabaseConcurrency
|
||||
import ru.fromchat.db.MessageDatabase
|
||||
|
||||
/**
|
||||
@@ -34,16 +35,19 @@ object MessageDatabaseProvider {
|
||||
}
|
||||
|
||||
/** Runs [block] once; on a stale connection after cache wipe, resets and retries. */
|
||||
internal fun <T> withDatabaseRecover(block: () -> T): T =
|
||||
withSqliteBusyRetry {
|
||||
try {
|
||||
block()
|
||||
} catch (e: Exception) {
|
||||
if (!isStaleSqliteConnection(e)) throw e
|
||||
closeAndReset()
|
||||
block()
|
||||
internal fun <T> withDatabaseRecover(block: () -> T): T {
|
||||
try {
|
||||
return block()
|
||||
} catch (e: Exception) {
|
||||
if (isSqliteBusy(e)) {
|
||||
MessageDatabaseConcurrency.notifySqliteBusy(e)
|
||||
throw e
|
||||
}
|
||||
if (!isStaleSqliteConnection(e)) throw e
|
||||
closeAndReset()
|
||||
return block()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isStaleSqliteConnection(throwable: Throwable): Boolean {
|
||||
var current: Throwable? = throwable
|
||||
|
||||
+4
-5
@@ -1,6 +1,5 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.fromchat.api.schema.user.profile.UserProfile
|
||||
@@ -12,7 +11,7 @@ object ProfileCacheStore {
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
suspend fun get(instanceId: String, userId: Int): UserProfile? = withContext(Dispatchers.Default) {
|
||||
suspend fun get(instanceId: String, userId: Int): UserProfile? = withContext(messageDatabaseDispatcher) {
|
||||
val id = instanceId.trim()
|
||||
if (id.isEmpty()) return@withContext null
|
||||
val raw = MessageDatabaseProvider.database.messageDatabaseQueries
|
||||
@@ -24,7 +23,7 @@ object ProfileCacheStore {
|
||||
suspend fun put(instanceId: String, profile: UserProfile) {
|
||||
val id = instanceId.trim()
|
||||
if (id.isEmpty()) return
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
MessageDatabaseProvider.database.messageDatabaseQueries.upsertProfileCache(
|
||||
instanceId = id,
|
||||
userId = profile.id.toLong(),
|
||||
@@ -36,7 +35,7 @@ object ProfileCacheStore {
|
||||
suspend fun remove(instanceId: String, userId: Int) {
|
||||
val id = instanceId.trim()
|
||||
if (id.isEmpty()) return
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
MessageDatabaseProvider.database.messageDatabaseQueries.deleteProfileCache(
|
||||
instanceId = id,
|
||||
userId = userId.toLong(),
|
||||
@@ -45,7 +44,7 @@ object ProfileCacheStore {
|
||||
}
|
||||
|
||||
suspend fun loadAllForInstance(instanceId: String): Map<Int, UserProfile> =
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
val id = instanceId.trim()
|
||||
if (id.isEmpty()) return@withContext emptyMap()
|
||||
MessageDatabaseProvider.database.messageDatabaseQueries
|
||||
|
||||
+3
-4
@@ -1,6 +1,5 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
|
||||
@@ -30,14 +29,14 @@ object PublicChatProfileCacheStore {
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun get(instanceId: String): PublicChatProfile? = withContext(Dispatchers.Default) {
|
||||
suspend fun get(instanceId: String): PublicChatProfile? = withContext(messageDatabaseDispatcher) {
|
||||
getImmediate(instanceId)
|
||||
}
|
||||
|
||||
suspend fun put(instanceId: String, profile: PublicChatProfile) {
|
||||
val id = instanceId.trim()
|
||||
if (id.isEmpty()) return
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
putImmediate(id, profile)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +44,7 @@ object PublicChatProfileCacheStore {
|
||||
suspend fun remove(instanceId: String) {
|
||||
val id = instanceId.trim()
|
||||
if (id.isEmpty()) return
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
MessageDatabaseProvider.database.messageDatabaseQueries.deletePublicChatProfileForInstance(id)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -4,9 +4,11 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
|
||||
import ru.fromchat.api.local.db.store.messageDatabaseDispatcher
|
||||
import ru.fromchat.api.local.download.AttachmentDownloadNotifier
|
||||
import ru.fromchat.api.local.send.DmAttachmentOutboxPayload
|
||||
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
||||
@@ -50,9 +52,11 @@ object AttachmentTransferBootstrap {
|
||||
}
|
||||
|
||||
private suspend fun repairPendingAttachmentArtifacts(instanceId: String) {
|
||||
val rows = MessageDatabaseProvider.database.messageDatabaseQueries
|
||||
.selectPendingOutboxForInstance(instanceId)
|
||||
.executeAsList()
|
||||
val rows = withContext(messageDatabaseDispatcher) {
|
||||
MessageDatabaseProvider.database.messageDatabaseQueries
|
||||
.selectPendingOutboxForInstance(instanceId)
|
||||
.executeAsList()
|
||||
}
|
||||
for (row in rows) {
|
||||
if (
|
||||
row.kind != OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT &&
|
||||
|
||||
@@ -28,6 +28,8 @@ object Settings {
|
||||
private const val HTTPS_ENABLED_KEY = "https_enabled"
|
||||
private const val DEVICE_SESSIONS_CACHE_KEY = "device_sessions_cache_v1"
|
||||
private const val LAST_SERVER_INSTANCE_ID_KEY = "last_server_instance_id"
|
||||
private const val LAST_ACKNOWLEDGED_APP_VERSION_KEY = "last_acknowledged_app_version"
|
||||
private const val RELEASE_NOTES_PERMANENTLY_HIDDEN_KEY = "release_notes_permanently_hidden"
|
||||
|
||||
private val settings = PlatformSettings()
|
||||
private val deviceSessionsJson = Json { ignoreUnknownKeys = true }
|
||||
@@ -183,8 +185,22 @@ object Settings {
|
||||
runIO { settings.remove(DEVICE_SESSIONS_CACHE_KEY) }
|
||||
}
|
||||
|
||||
/** Suspend read for coroutine / single [runBlocking] callers (e.g. schema migration on the DB thread). */
|
||||
internal suspend fun readLastKnownServerInstanceIdRaw(): String =
|
||||
settings.getString(LAST_SERVER_INSTANCE_ID_KEY, "")
|
||||
|
||||
/** Last known [ServerInstanceIdResponse.instanceId] from the configured API (empty until first fetch). */
|
||||
var lastKnownServerInstanceId: String
|
||||
get() = runBlocking { settings.getString(LAST_SERVER_INSTANCE_ID_KEY, "") }
|
||||
get() = runBlocking { readLastKnownServerInstanceIdRaw() }
|
||||
set(value) = runIO { settings.putString(LAST_SERVER_INSTANCE_ID_KEY, value) }
|
||||
|
||||
/** App version for which the user last dismissed the release-notes prompt. */
|
||||
var lastAcknowledgedAppVersion: String
|
||||
get() = runBlocking { settings.getString(LAST_ACKNOWLEDGED_APP_VERSION_KEY, "") }
|
||||
set(value) = runIO { settings.putString(LAST_ACKNOWLEDGED_APP_VERSION_KEY, value) }
|
||||
|
||||
/** When true, the automatic post-update release-notes dialog is suppressed. */
|
||||
var releaseNotesPermanentlyHidden: Boolean
|
||||
get() = runBlocking { settings.getBoolean(RELEASE_NOTES_PERMANENTLY_HIDDEN_KEY, false) }
|
||||
set(value) = runIO { settings.putBoolean(RELEASE_NOTES_PERMANENTLY_HIDDEN_KEY, value) }
|
||||
}
|
||||
|
||||
@@ -305,6 +305,26 @@ internal fun MarkdownPlain(
|
||||
index += 1
|
||||
}
|
||||
|
||||
trimmed.startsWith("## ") -> {
|
||||
MarkdownBlockText(
|
||||
text = parseInlineMarkdown(trimmed.removePrefix("## "), linkStyle, onLinkClick),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
trimmed.startsWith("# ") -> {
|
||||
MarkdownBlockText(
|
||||
text = parseInlineMarkdown(trimmed.removePrefix("# "), linkStyle, onLinkClick),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
trimmed.startsWith("- ") -> {
|
||||
Row(modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) {
|
||||
Text(
|
||||
|
||||
@@ -48,6 +48,7 @@ import com.pr0gramm3r101.utils.widthSizeClass
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
@@ -71,6 +72,7 @@ import ru.fromchat.api.instance.scheduleSessionInstanceNetworkRefresh
|
||||
import ru.fromchat.api.local.WebSocketManager
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration
|
||||
import ru.fromchat.api.local.db.store.messageDatabaseDispatcher
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.local.db.store.UserStatusStore
|
||||
import ru.fromchat.api.local.messages.DmInboxCoordinator
|
||||
@@ -78,6 +80,8 @@ import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import ru.fromchat.ui.release.ReleaseNotesDialog
|
||||
import ru.fromchat.ui.release.ReleaseNotesPrompt
|
||||
import ru.fromchat.desktop.DesktopMenuCommand
|
||||
import ru.fromchat.desktop.DesktopMenuCommands
|
||||
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
||||
@@ -243,11 +247,13 @@ fun App(
|
||||
var sessionLogoutRequired by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.withContext(Dispatchers.Default) {
|
||||
runCatching { ServerConfig.initialize() }
|
||||
runCatching { ServerConfig.initialize() }
|
||||
withContext(messageDatabaseDispatcher) {
|
||||
runCatching { ensureFromChatCacheGeneration() }
|
||||
runCatching { NetworkConnectivity.ensureStarted() }
|
||||
runCatching { ApiClient.loadPersistedData() }
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
runCatching { NetworkConnectivity.ensureStarted() }
|
||||
runCatching { UpdateSyncManager.initializeFromStorage(ApiClient.user?.id) }
|
||||
Logger.i("App", "FromChat started")
|
||||
}
|
||||
@@ -487,6 +493,12 @@ fun App(
|
||||
}
|
||||
|
||||
val fullscreenImageController = remember { ChatFullscreenImageController() }
|
||||
var showReleaseNotesPrompt by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(startDestination) {
|
||||
if (startDestination != null && ReleaseNotesPrompt.shouldShowAutomatically()) {
|
||||
showReleaseNotesPrompt = true
|
||||
}
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalNavController provides navController,
|
||||
LocalDesktopChatsNavController provides
|
||||
@@ -758,6 +770,9 @@ fun App(
|
||||
|
||||
ChatFullscreenImageHost(Modifier.fillMaxSize())
|
||||
CallOverlay(Modifier.fillMaxSize())
|
||||
if (showReleaseNotesPrompt) {
|
||||
ReleaseNotesDialog(onDismiss = { showReleaseNotesPrompt = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material.icons.outlined.NewReleases
|
||||
import androidx.compose.material.icons.outlined.Shield
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -18,6 +19,10 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import ru.fromchat.ui.components.Text
|
||||
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 androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -43,11 +48,13 @@ import ru.fromchat.about_link_telegram
|
||||
import ru.fromchat.about_link_terms
|
||||
import ru.fromchat.about_link_website
|
||||
import ru.fromchat.about_version
|
||||
import ru.fromchat.about_what_changed
|
||||
import ru.fromchat.app_desc
|
||||
import ru.fromchat.logo_square
|
||||
import ru.fromchat.legal.DocumentType
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.components.BrandTitle
|
||||
import ru.fromchat.ui.release.ReleaseNotesDialog
|
||||
|
||||
private const val URL_TELEGRAM = "https://t.me/fromchat_ch"
|
||||
private const val URL_MAX = "https://maxgate.io/fromchat_ch"
|
||||
@@ -60,6 +67,11 @@ fun AboutScreen() {
|
||||
val scrollBehavior = rememberSettingsCollapsingScrollBehavior()
|
||||
val navController = LocalNavController.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
var showReleaseNotes by remember { mutableStateOf(false) }
|
||||
|
||||
if (showReleaseNotes) {
|
||||
ReleaseNotesDialog(onDismiss = { showReleaseNotes = false })
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
@@ -125,6 +137,22 @@ fun AboutScreen() {
|
||||
}
|
||||
|
||||
Category(Modifier.padding(top = 16.dp)) {
|
||||
if (AppBuildInfo.releaseNotesMarkdown.isNotBlank()) {
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.about_what_changed),
|
||||
onClick = { showReleaseNotes = true },
|
||||
divider = true,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.NewReleases,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.about_link_telegram),
|
||||
supportingText = URL_TELEGRAM,
|
||||
|
||||
@@ -29,10 +29,6 @@ import androidx.compose.material.icons.automirrored.filled.Login
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.rounded.Android
|
||||
import androidx.compose.material.icons.rounded.Language
|
||||
import androidx.compose.material.icons.rounded.LaptopMac
|
||||
import androidx.compose.material.icons.rounded.PhoneAndroid
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -60,16 +56,13 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.DrawableResource
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import com.pr0gramm3r101.components.Category
|
||||
import com.pr0gramm3r101.components.ListItem
|
||||
import com.pr0gramm3r101.utils.currentDeviceInfo
|
||||
import dev.chrisbanes.haze.HazeInput
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import dev.chrisbanes.haze.blur.hazeBlur
|
||||
@@ -85,9 +78,6 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.os_linux
|
||||
import ru.fromchat.os_macos
|
||||
import ru.fromchat.os_windows
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.WebSocketManager
|
||||
import ru.fromchat.api.schema.user.devices.DeviceSessionInfo
|
||||
@@ -127,156 +117,12 @@ private fun isUnreachableDevicesFetchError(error: Throwable): Boolean = when (er
|
||||
else -> true
|
||||
}
|
||||
|
||||
private fun enrichDevicesList(list: List<DeviceSessionInfo>): List<DeviceSessionInfo> {
|
||||
val currentIndex = list.indexOfFirst { it.current }
|
||||
if (currentIndex < 0) return list
|
||||
return list
|
||||
.toMutableList()
|
||||
.also { it[currentIndex] = deviceSessionForCurrentDevice(it[currentIndex]) }
|
||||
}
|
||||
|
||||
private fun formatDeviceLine(d: DeviceSessionInfo, fallbackLabel: String) =
|
||||
listOfNotNull(
|
||||
d.deviceName,
|
||||
d.brand,
|
||||
d.model,
|
||||
deviceSessionOsLine(d),
|
||||
d.browserName,
|
||||
d.deviceType
|
||||
)
|
||||
.mapNotNull { it.trim().takeIf { it.isNotBlank() } }
|
||||
.distinct()
|
||||
.let {
|
||||
if (it.isNotEmpty()) {
|
||||
it.joinToString(" • ")
|
||||
} else {
|
||||
d.deviceType?.takeIf { it.isNotBlank() } ?: fallbackLabel
|
||||
}
|
||||
}
|
||||
|
||||
private fun deviceSessionForCurrentDevice(d: DeviceSessionInfo): DeviceSessionInfo {
|
||||
if (d.current) {
|
||||
val current = currentDeviceInfo()
|
||||
|
||||
val localOsName = current.osName?.trim()?.takeIf { it.isNotBlank() }
|
||||
val localOsVersion = current.osVersion?.trim()?.takeIf { it.isNotBlank() }
|
||||
val localDeviceType = current.deviceType?.trim()?.takeIf { it.isNotBlank() }
|
||||
val localDeviceName = current.deviceName?.trim()?.takeIf { it.isNotBlank() }
|
||||
val localBrand = current.brand?.trim()?.takeIf { it.isNotBlank() }
|
||||
val localModel = current.model?.trim()?.takeIf { it.isNotBlank() }
|
||||
val remoteDeviceName = d.deviceName?.trim()?.takeIf { it.isNotBlank() }
|
||||
val remoteBrand = d.brand?.trim()?.takeIf { it.isNotBlank() }
|
||||
val remoteModel = d.model?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
return d.copy(
|
||||
osName = localOsName ?: d.osName,
|
||||
osVersion = localOsVersion ?: d.osVersion,
|
||||
deviceType = localDeviceType ?: d.deviceType,
|
||||
deviceName = remoteDeviceName ?: localDeviceName ?: d.deviceName,
|
||||
brand = remoteBrand ?: localBrand ?: d.brand,
|
||||
model = remoteModel ?: localModel ?: d.model
|
||||
)
|
||||
} else {
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatDeviceLastSeen(iso: String?): String {
|
||||
if (iso.isNullOrBlank()) return stringResource(Res.string.unknown)
|
||||
return iso.replace("T", " ").take(19)
|
||||
}
|
||||
|
||||
private fun resolveDeviceOsName(d: DeviceSessionInfo): String? {
|
||||
val direct = normalizeDeviceOsName(d.osName)
|
||||
if (direct != null) return direct
|
||||
return normalizeDeviceOsName(inferDeviceOsFromSession(d))
|
||||
}
|
||||
|
||||
private fun normalizeDeviceOsName(raw: String?): String? {
|
||||
val trimmed = raw?.trim() ?: return null
|
||||
if (trimmed.isBlank()) return null
|
||||
val lower = trimmed.lowercase()
|
||||
return when {
|
||||
"windows" in lower || "win" in lower -> "Windows"
|
||||
"mac" in lower || "os x" in lower || "darwin" in lower -> "macOS"
|
||||
"linux" in lower -> "Linux"
|
||||
"android" in lower -> "Android"
|
||||
"ios" in lower || "iphone" in lower || "ipad" in lower -> "iOS"
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private fun inferDeviceOsFromSession(d: DeviceSessionInfo): String? {
|
||||
val type = d.deviceType?.lowercase().orEmpty()
|
||||
val name = d.deviceName?.lowercase().orEmpty()
|
||||
val brand = d.brand?.lowercase().orEmpty()
|
||||
val model = d.model?.lowercase().orEmpty()
|
||||
val browser = d.browserName?.lowercase().orEmpty()
|
||||
val isMobile = "mobile" in type || "tablet" in type || "phone" in type
|
||||
return when {
|
||||
isMobile && (brand.contains("apple") || name.contains("iphone") || name.contains("ipad") || model.contains("iphone") || model.contains("ipad") || browser.contains("ios")) -> "iOS"
|
||||
isMobile && (brand.contains("android") || name.contains("android") || model.contains("android") || browser.contains("android")) -> "Android"
|
||||
isMobile && (
|
||||
brand.contains("samsung") ||
|
||||
brand.contains("xiaomi") ||
|
||||
brand.contains("huawei") ||
|
||||
brand.contains("oppo") ||
|
||||
brand.contains("vivo") ||
|
||||
brand.contains("pixel") ||
|
||||
brand.contains("oneplus") ||
|
||||
brand.contains("google") ||
|
||||
brand.contains("lg") ||
|
||||
brand.contains("motorola") ||
|
||||
brand.contains("honor") ||
|
||||
brand.contains("realme")
|
||||
) -> "Android"
|
||||
"android" in browser || "android" in brand || "android" in model -> "Android"
|
||||
brand.contains("apple") || model.contains("iphone") || model.contains("ipad") || browser.contains("ios") || browser.contains("iphone") || browser.contains("ipad") -> "iOS"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun deviceSessionOsLine(d: DeviceSessionInfo): String? {
|
||||
val os = resolveDeviceOsName(d) ?: return null
|
||||
val version = d.osVersion?.trim().orEmpty()
|
||||
return if (version.isBlank()) os else "$os $version"
|
||||
}
|
||||
|
||||
private fun deviceSessionLogoResource(d: DeviceSessionInfo): DrawableResource? {
|
||||
return when (resolveDeviceOsName(d)?.lowercase()) {
|
||||
"windows", "windows nt" -> Res.drawable.os_windows
|
||||
"mac", "mac os", "macos" -> Res.drawable.os_macos
|
||||
"linux" -> Res.drawable.os_linux
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun deviceHeadline(d: DeviceSessionInfo, fallback: String): String {
|
||||
d.deviceName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
val brandModel = listOfNotNull(d.brand?.trim(), d.model?.trim())
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" ")
|
||||
if (brandModel.isNotEmpty()) return brandModel
|
||||
return formatDeviceLine(d, fallback)
|
||||
}
|
||||
|
||||
private fun deviceSessionIcon(d: DeviceSessionInfo): ImageVector {
|
||||
val type = d.deviceType?.lowercase().orEmpty()
|
||||
val os = resolveDeviceOsName(d)?.lowercase().orEmpty()
|
||||
val hasBrowser = d.browserName?.isNotBlank() == true
|
||||
|
||||
return when {
|
||||
"android" in os -> Icons.Rounded.Android
|
||||
// "ios" in os || "iphone" in os -> TODO()
|
||||
"mobile" in type || "phone" in type -> Icons.Rounded.PhoneAndroid
|
||||
hasBrowser && "windows" !in os && "mac" !in os && "linux" !in os &&
|
||||
"android" !in os && "ios" !in os -> Icons.Rounded.Language
|
||||
hasBrowser -> Icons.Rounded.Language
|
||||
else -> Icons.Rounded.LaptopMac
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun DeviceSessionDetailBottomSheet(
|
||||
@@ -305,7 +151,7 @@ private fun DeviceSessionDetailBottomSheet(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = deviceHeadline(d, unknownDeviceLabel),
|
||||
text = deviceSessionHeadline(d, unknownDeviceLabel),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
@@ -535,7 +381,7 @@ fun DevicesScreen(onBack: () -> Unit) {
|
||||
|
||||
ListItem(
|
||||
headline = remember(device) {
|
||||
deviceHeadline(device, unknownDeviceLabel)
|
||||
deviceSessionHeadline(device, unknownDeviceLabel)
|
||||
},
|
||||
supportingText = stringResource(
|
||||
Res.string.settings_devices_last_active,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package ru.fromchat.ui.release
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.NewReleases
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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 androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.AppBuildInfo
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.config.Settings
|
||||
import ru.fromchat.legal.Markdown
|
||||
import ru.fromchat.release_notes_dont_show_again
|
||||
import ru.fromchat.release_notes_ok
|
||||
import ru.fromchat.release_notes_title
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveIconFrame
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
object ReleaseNotesPrompt {
|
||||
fun shouldShowAutomatically(): Boolean {
|
||||
if (Settings.releaseNotesPermanentlyHidden) return false
|
||||
if (AppBuildInfo.releaseNotesMarkdown.isBlank()) return false
|
||||
return Settings.lastAcknowledgedAppVersion != AppBuildInfo.version
|
||||
}
|
||||
|
||||
fun applyDismissal(dontShowAgain: Boolean) {
|
||||
Settings.releaseNotesPermanentlyHidden = dontShowAgain
|
||||
Settings.lastAcknowledgedAppVersion = AppBuildInfo.version
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ReleaseNotesDialog(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val markdown = AppBuildInfo.releaseNotesMarkdown
|
||||
if (markdown.isBlank()) return
|
||||
|
||||
var dontShowAgain by remember { mutableStateOf(Settings.releaseNotesPermanentlyHidden) }
|
||||
val contentPadding = 24.dp
|
||||
|
||||
fun dismiss() {
|
||||
ReleaseNotesPrompt.applyDismissal(dontShowAgain)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { dismiss() },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 420.dp)
|
||||
.padding(contentPadding),
|
||||
) {
|
||||
Column(Modifier.padding(contentPadding)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
) {
|
||||
ExpressiveIconFrame(
|
||||
icon = Icons.Outlined.NewReleases,
|
||||
containerSize = 48.dp,
|
||||
iconSize = 24.dp,
|
||||
materialPolygon = MaterialShapes.SoftBurst,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.release_notes_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
|
||||
Markdown(
|
||||
content = markdown,
|
||||
modifier = Modifier
|
||||
.heightIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 16.dp),
|
||||
)
|
||||
|
||||
val dontShowAgainInteraction = remember { MutableInteractionSource() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.hoverable(dontShowAgainInteraction)
|
||||
.clickable(
|
||||
interactionSource = dontShowAgainInteraction,
|
||||
indication = null,
|
||||
onClick = { dontShowAgain = !dontShowAgain },
|
||||
)
|
||||
.padding(bottom = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = dontShowAgain,
|
||||
onCheckedChange = null,
|
||||
interactionSource = dontShowAgainInteraction,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.release_notes_dont_show_again),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
ActionButton(onClick = { dismiss() }) {
|
||||
Text(stringResource(Res.string.release_notes_ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
internal actual val messageDatabaseDispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
@@ -1,7 +1,7 @@
|
||||
package ru.fromchat.api.local.db
|
||||
|
||||
internal actual fun <T> withMessageDatabaseLock(block: () -> T): T =
|
||||
withSqliteBusyRetry {
|
||||
withSqliteBusyGuard {
|
||||
synchronized(MessageDatabaseLock) { block() }
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
||||
internal actual val messageDatabaseDispatcher: CoroutineDispatcher = SingleThreadSqlDriver.dispatcher
|
||||
+15
-14
@@ -6,23 +6,24 @@ import com.pr0gramm3r101.utils.files.PlatformFileSystem
|
||||
import java.io.File
|
||||
import ru.fromchat.db.MessageDatabase
|
||||
|
||||
actual fun provideMessageDatabaseDriver(): SqlDriver {
|
||||
val dir = File(PlatformFileSystem.getAppCacheDirectory(), "fromchat")
|
||||
dir.mkdirs()
|
||||
val dbFile = File(dir, "message_database.db")
|
||||
val needsCreate = !dbFile.exists()
|
||||
val jdbc = JdbcSqliteDriver(
|
||||
"jdbc:sqlite:${dbFile.absolutePath}?busy_timeout=30000&journal_mode=WAL",
|
||||
)
|
||||
if (needsCreate) {
|
||||
MessageDatabase.Schema.create(jdbc)
|
||||
actual fun provideMessageDatabaseDriver(): SqlDriver =
|
||||
SingleThreadSqlDriver.run {
|
||||
val dir = File(PlatformFileSystem.getAppCacheDirectory(), "fromchat")
|
||||
dir.mkdirs()
|
||||
val dbFile = File(dir, "message_database.db")
|
||||
val needsCreate = !dbFile.exists()
|
||||
val jdbc = JdbcSqliteDriver(
|
||||
"jdbc:sqlite:${dbFile.absolutePath}?busy_timeout=0&journal_mode=WAL",
|
||||
)
|
||||
if (needsCreate) {
|
||||
MessageDatabase.Schema.create(jdbc)
|
||||
}
|
||||
configureJvmSqliteDriver(jdbc)
|
||||
SingleThreadSqlDriver(jdbc)
|
||||
}
|
||||
configureJvmSqliteDriver(jdbc)
|
||||
return SynchronizedRetryingSqlDriver(jdbc)
|
||||
}
|
||||
|
||||
private fun configureJvmSqliteDriver(driver: SqlDriver) {
|
||||
driver.execute(null, "PRAGMA busy_timeout = 30000;", 0)
|
||||
driver.execute(null, "PRAGMA busy_timeout = 0;", 0)
|
||||
driver.execute(null, "PRAGMA journal_mode = WAL;", 0)
|
||||
driver.execute(null, "PRAGMA synchronous = NORMAL;", 0)
|
||||
}
|
||||
|
||||
+20
-17
@@ -1,8 +1,6 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
||||
import com.pr0gramm3r101.utils.files.PlatformFileSystem
|
||||
import ru.fromchat.api.local.db.isSqliteBusy
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.channels.OverlappingFileLockException
|
||||
@@ -23,11 +21,28 @@ object MessageDatabasePaths {
|
||||
).filter { it.exists() }
|
||||
}
|
||||
|
||||
fun isDatabaseLocked(): Boolean {
|
||||
/** Database files plus the desktop instance lock sidecar for handle-based lock discovery. */
|
||||
fun lockProbeFiles(): List<File> {
|
||||
val db = databaseFile()
|
||||
val instanceLock = File(db.parentFile, "message_database.instance.lock")
|
||||
return listOf(
|
||||
db,
|
||||
File("${db.path}-wal"),
|
||||
File("${db.path}-shm"),
|
||||
File("${db.path}-journal"),
|
||||
instanceLock,
|
||||
).distinctBy { it.absolutePath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort check for a **foreign** lock while this process does **not** have the DB open.
|
||||
* Returns false when only this process holds the files (JDBC keeps them open), so do not use
|
||||
* this while the app is running — use handle-based discovery instead.
|
||||
*/
|
||||
fun isDatabaseLockedByAnotherProcess(): Boolean {
|
||||
val targets = lockTargetFiles()
|
||||
if (targets.isEmpty()) return false
|
||||
if (targets.any { isFileLockedExclusively(it) }) return true
|
||||
return isSqliteWriteLocked(databaseFile())
|
||||
return targets.any { isFileLockedExclusively(it) }
|
||||
}
|
||||
|
||||
private fun isFileLockedExclusively(file: File): Boolean =
|
||||
@@ -43,16 +58,4 @@ object MessageDatabasePaths {
|
||||
lock == null
|
||||
}
|
||||
}.getOrDefault(true)
|
||||
|
||||
private fun isSqliteWriteLocked(dbFile: File): Boolean =
|
||||
runCatching {
|
||||
val probe = JdbcSqliteDriver("jdbc:sqlite:${dbFile.absolutePath}?busy_timeout=1")
|
||||
try {
|
||||
probe.execute(null, "BEGIN IMMEDIATE;", 0)
|
||||
probe.execute(null, "ROLLBACK;", 0)
|
||||
false
|
||||
} finally {
|
||||
probe.close()
|
||||
}
|
||||
}.getOrElse { isSqliteBusy(it) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import app.cash.sqldelight.Query
|
||||
import app.cash.sqldelight.Transacter
|
||||
import app.cash.sqldelight.db.QueryResult
|
||||
import app.cash.sqldelight.db.SqlCursor
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.db.SqlPreparedStatement
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import ru.fromchat.api.local.db.withSqliteBusyGuard
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* JDBC SQLite connections are not thread-safe. All access is serialized on one background thread.
|
||||
*/
|
||||
internal class SingleThreadSqlDriver(
|
||||
private val delegate: SqlDriver,
|
||||
) : SqlDriver {
|
||||
private fun <T> onDbThread(block: () -> T): T {
|
||||
if (Thread.currentThread() == dbThread.get()) {
|
||||
return withSqliteBusyGuard(block)
|
||||
}
|
||||
return executor.submit(
|
||||
Callable { withSqliteBusyGuard(block) },
|
||||
).get()
|
||||
}
|
||||
|
||||
override fun close() = onDbThread { delegate.close() }
|
||||
|
||||
override fun execute(
|
||||
identifier: Int?,
|
||||
sql: String,
|
||||
parameters: Int,
|
||||
binders: (SqlPreparedStatement.() -> Unit)?,
|
||||
): QueryResult<Long> = onDbThread { delegate.execute(identifier, sql, parameters, binders) }
|
||||
|
||||
override fun <R> executeQuery(
|
||||
identifier: Int?,
|
||||
sql: String,
|
||||
mapper: (SqlCursor) -> QueryResult<R>,
|
||||
parameters: Int,
|
||||
binders: (SqlPreparedStatement.() -> Unit)?,
|
||||
): QueryResult<R> = onDbThread { delegate.executeQuery(identifier, sql, mapper, parameters, binders) }
|
||||
|
||||
override fun newTransaction(): QueryResult<Transacter.Transaction> = onDbThread { delegate.newTransaction() }
|
||||
|
||||
override fun currentTransaction(): Transacter.Transaction? = onDbThread { delegate.currentTransaction() }
|
||||
|
||||
override fun addListener(vararg queryKeys: String, listener: Query.Listener) =
|
||||
onDbThread { delegate.addListener(*queryKeys, listener = listener) }
|
||||
|
||||
override fun removeListener(vararg queryKeys: String, listener: Query.Listener) =
|
||||
onDbThread { delegate.removeListener(*queryKeys, listener = listener) }
|
||||
|
||||
override fun notifyListeners(vararg queryKeys: String) =
|
||||
onDbThread { delegate.notifyListeners(*queryKeys) }
|
||||
|
||||
companion object {
|
||||
private val dbThread = AtomicReference<Thread>()
|
||||
private val executor = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "fromchat-sqlite").apply {
|
||||
isDaemon = true
|
||||
dbThread.set(this)
|
||||
}
|
||||
}
|
||||
|
||||
val dispatcher: CoroutineDispatcher = executor.asCoroutineDispatcher()
|
||||
|
||||
fun <T> run(block: () -> T): T {
|
||||
if (Thread.currentThread() == dbThread.get()) {
|
||||
return block()
|
||||
}
|
||||
return executor.submit(Callable(block)).get()
|
||||
}
|
||||
}
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import app.cash.sqldelight.Query
|
||||
import app.cash.sqldelight.Transacter
|
||||
import app.cash.sqldelight.db.QueryResult
|
||||
import app.cash.sqldelight.db.SqlCursor
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.db.SqlPreparedStatement
|
||||
import ru.fromchat.api.local.db.withSqliteBusyRetry
|
||||
|
||||
/**
|
||||
* Serializes JDBC SQLite access and retries transient [SQLITE_BUSY] errors.
|
||||
* Desktop can hit the DB from multiple threads while the provider lock is not held per query.
|
||||
*/
|
||||
internal class SynchronizedRetryingSqlDriver(
|
||||
private val delegate: SqlDriver,
|
||||
) : SqlDriver {
|
||||
private val accessLock = Any()
|
||||
|
||||
override fun close() = synchronized(accessLock) { delegate.close() }
|
||||
|
||||
override fun execute(
|
||||
identifier: Int?,
|
||||
sql: String,
|
||||
parameters: Int,
|
||||
binders: (SqlPreparedStatement.() -> Unit)?,
|
||||
): QueryResult<Long> = synchronized(accessLock) {
|
||||
withSqliteBusyRetry { delegate.execute(identifier, sql, parameters, binders) }
|
||||
}
|
||||
|
||||
override fun <R> executeQuery(
|
||||
identifier: Int?,
|
||||
sql: String,
|
||||
mapper: (SqlCursor) -> QueryResult<R>,
|
||||
parameters: Int,
|
||||
binders: (SqlPreparedStatement.() -> Unit)?,
|
||||
): QueryResult<R> = synchronized(accessLock) {
|
||||
withSqliteBusyRetry { delegate.executeQuery(identifier, sql, mapper, parameters, binders) }
|
||||
}
|
||||
|
||||
override fun newTransaction(): QueryResult<Transacter.Transaction> = synchronized(accessLock) {
|
||||
withSqliteBusyRetry { delegate.newTransaction() }
|
||||
}
|
||||
|
||||
override fun currentTransaction(): Transacter.Transaction? = synchronized(accessLock) {
|
||||
delegate.currentTransaction()
|
||||
}
|
||||
|
||||
override fun addListener(vararg queryKeys: String, listener: Query.Listener) {
|
||||
delegate.addListener(*queryKeys, listener = listener)
|
||||
}
|
||||
|
||||
override fun removeListener(vararg queryKeys: String, listener: Query.Listener) {
|
||||
delegate.removeListener(*queryKeys, listener = listener)
|
||||
}
|
||||
|
||||
override fun notifyListeners(vararg queryKeys: String) {
|
||||
delegate.notifyListeners(*queryKeys)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -9,8 +9,8 @@ plugins {
|
||||
}
|
||||
|
||||
/** Single source of truth for app version (APK + generated [AppBuildInfo]). */
|
||||
extra["versionName"] = "1.1.4"
|
||||
extra["versionCode"] = 114
|
||||
extra["versionName"] = "1.2.0"
|
||||
extra["versionCode"] = 120
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
|
||||
Reference in New Issue
Block a user