Fix SQLite thread safety, add "What's new", single source of truth for app version, improve database lock UI

This commit is contained in:
2026-09-13 23:51:45 +03:00
Unverified
parent 538819255d
commit 9f9a1f9874
42 changed files with 972 additions and 589 deletions
+3
View File
@@ -0,0 +1,3 @@
- Наконец-то появилась десктопная версия FromChat! Теперь вы можете общаться прямо с вашего компьютера на Windows, Linux и macOS. Даже старые Mac на Intel поддерживаются.
- На Android и десктопе вы теперь можете напрямую перетаскивать файлы и картинки прямо в чат без необходимости долго их искать.
- Улучшен экран устройств: теперь более наглядно видно, на каких устройствах вы вошли в аккаунт. И иконки теперь везде одинаковые.
@@ -19,38 +19,73 @@ object DatabaseLockGate {
private val _processes = MutableStateFlow<List<LockingProcessInfo>>(emptyList()) private val _processes = MutableStateFlow<List<LockingProcessInfo>>(emptyList())
val processes: StateFlow<List<LockingProcessInfo>> = _processes.asStateFlow() val processes: StateFlow<List<LockingProcessInfo>> = _processes.asStateFlow()
private val _runtimeSqliteBusy = MutableStateFlow(false)
private var monitorJob: Job? = null 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) { fun startMonitoring(scope: CoroutineScope, onUnlocked: () -> Unit) {
monitorJob?.cancel() monitorJob?.cancel()
monitorJob = scope.launch { monitorJob = scope.launch {
var everBlocked = false
var bootstrapDelivered = false var bootstrapDelivered = false
while (isActive) { while (isActive) {
val locked = MessageDatabasePaths.isDatabaseLocked() val blocked = refreshBlockedState()
if (locked) { if (!blocked && !bootstrapDelivered) {
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 bootstrapDelivered = true
onUnlocked() onUnlocked()
} }
if (!everBlocked) return@launch if (!blocked && bootstrapDelivered) return@launch
if (!_blocked.value) return@launch
}
delay(750.milliseconds) delay(750.milliseconds)
} }
} }
} }
fun killProcess(pid: Long): Boolean = DatabaseLockingProcessResolver.killProcess(pid) 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.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn 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.Icons
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme 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.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
@@ -30,31 +34,56 @@ import androidx.compose.ui.window.rememberWindowState
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.desktop_database_locked_kill 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_message
import ru.fromchat.desktop_database_locked_pid
import ru.fromchat.desktop_database_locked_title 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_database_locked_waiting
import ru.fromchat.desktop_quit 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 @Composable
fun DatabaseLockWindow( fun DatabaseLockWindow(
appName: String,
windowIcon: Painter,
dockIconImage: BufferedImage?,
processes: List<LockingProcessInfo>, processes: List<LockingProcessInfo>,
onKillProcess: (Long) -> Unit, onKillProcess: (Long) -> Unit,
onQuit: () -> Unit, onQuit: () -> Unit,
) { ) {
val title = stringResource(Res.string.desktop_database_locked_title) val windows = remember { isWindowsOs() }
val windowState = rememberWindowState(width = 520.dp, height = 420.dp) val windowState = rememberWindowState(width = 420.dp, height = 480.dp)
Window( Window(
onCloseRequest = onQuit, onCloseRequest = onQuit,
title = title, title = appName,
state = windowState, state = windowState,
icon = windowIcon,
undecorated = windows,
resizable = false, resizable = false,
alwaysOnTop = true,
) { ) {
FromChatTheme(darkTheme = desktopAppDarkTheme()) { DesktopRootSurface(
Surface(color = MaterialTheme.colorScheme.background) { 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( DatabaseLockContent(
processes = processes, processes = processes,
onKillProcess = onKillProcess, onKillProcess = onKillProcess,
@@ -65,80 +94,84 @@ fun DatabaseLockWindow(
} }
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun DatabaseLockContent( private fun DatabaseLockContent(
processes: List<LockingProcessInfo>, processes: List<LockingProcessInfo>,
onKillProcess: (Long) -> Unit, onKillProcess: (Long) -> Unit,
onQuit: () -> Unit, onQuit: () -> Unit,
) { ) {
val unknownProcess = stringResource(Res.string.desktop_database_locked_unknown_process)
Column( Column(
modifier = Modifier modifier = Modifier
.widthIn(min = 480.dp, max = 520.dp) .widthIn(max = 360.dp)
.padding(24.dp), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
) { ) {
Row( ExpressiveIconFrame(
verticalAlignment = Alignment.CenterVertically, icon = Icons.Default.Warning,
horizontalArrangement = Arrangement.spacedBy(12.dp), containerSize = 56.dp,
) { iconSize = 28.dp,
Icon( containerColor = MaterialTheme.colorScheme.errorContainer,
imageVector = Icons.Default.Warning, contentColor = MaterialTheme.colorScheme.onErrorContainer,
contentDescription = null, materialPolygon = MaterialShapes.SoftBurst,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(32.dp),
) )
Text( Text(
text = stringResource(Res.string.desktop_database_locked_title), text = stringResource(Res.string.desktop_database_locked_title),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
) )
}
Text( Text(
text = stringResource(Res.string.desktop_database_locked_message), text = stringResource(Res.string.desktop_database_locked_message),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
) )
if (processes.isEmpty()) {
LockingProcessRow( if (processes.isNotEmpty()) {
process = LockingProcessInfo( Column(
pid = -1L, modifier = Modifier.fillMaxWidth(),
name = unknownProcess, verticalArrangement = Arrangement.spacedBy(16.dp),
description = null, ) {
executablePath = null,
icon = null,
),
killEnabled = false,
onKill = {},
)
} else {
processes.forEach { process -> processes.forEach { process ->
LockingProcessRow( LockingProcessRow(
process = process, process = process,
killEnabled = process.pid > 0L,
onKill = { onKillProcess(process.pid) }, onKill = { onKillProcess(process.pid) },
) )
} }
} }
Row( } 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(), modifier = Modifier.fillMaxWidth(),
)
}
Row(
verticalAlignment = Alignment.CenterVertically, 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(
text = stringResource(Res.string.desktop_database_locked_waiting), text = stringResource(Res.string.desktop_database_locked_waiting),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Spacer(modifier = Modifier.height(4.dp))
Row( TextCta(
onClick = onQuit,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) { ) {
OutlinedButton(onClick = onQuit) { Text(
Text(stringResource(Res.string.desktop_quit)) text = stringResource(Res.string.desktop_quit),
} modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
} }
} }
} }
@@ -146,31 +179,23 @@ private fun DatabaseLockContent(
@Composable @Composable
private fun LockingProcessRow( private fun LockingProcessRow(
process: LockingProcessInfo, process: LockingProcessInfo,
killEnabled: Boolean,
onKill: () -> Unit, onKill: () -> Unit,
) {
Surface(
tonalElevation = 1.dp,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (process.icon != null) { if (process.icon != null) {
Image( Image(
bitmap = process.icon, bitmap = process.icon,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(40.dp), modifier = Modifier.size(36.dp),
) )
} else { } else {
Icon( Icon(
imageVector = Icons.Default.Warning, imageVector = Icons.Default.Warning,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(40.dp), modifier = Modifier.size(36.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
@@ -178,7 +203,7 @@ private fun LockingProcessRow(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = process.name, text = process.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -199,12 +224,11 @@ private fun LockingProcessRow(
) )
} }
} }
if (killEnabled) { if (process.pid > 0L) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Button(onClick = onKill) { ActionButton(onClick = onKill) {
Text(stringResource(Res.string.desktop_database_locked_kill)) Text(stringResource(Res.string.desktop_database_locked_kill))
} }
} }
} }
} }
}
@@ -6,17 +6,14 @@ import java.util.concurrent.TimeUnit
object DatabaseLockingProcessResolver { object DatabaseLockingProcessResolver {
fun findLockingProcesses(files: List<File>): List<LockingProcessInfo> { fun findLockingProcesses(files: List<File>): List<LockingProcessInfo> {
val discovered = buildList { val probeFiles = files
if (isWindowsOs()) { .map { file -> runCatching { file.canonicalFile }.getOrDefault(file) }
addAll(WindowsRestartManager.findLockingProcesses(files)) .distinctBy { it.absolutePath }
} val discovered = when {
if (isMacOs()) { isWindowsOs() -> WindowsRestartManager.findLockingProcesses(probeFiles)
addAll(findWithLsof(files)) isMacOs() -> findWithLsof(probeFiles.filter { it.exists() })
} isLinuxOs() -> findWithFuser(probeFiles.filter { it.exists() })
if (isLinuxOs()) { else -> emptyList()
addAll(findWithFuser(files))
}
addAll(ProcessExecutableResolver.findFromChatProcesses())
} }
return discovered return discovered
.distinctBy { it.pid } .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.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.zIndex 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.Notification
import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowExceptionHandler
import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState import androidx.compose.ui.window.rememberTrayState
@@ -56,9 +54,11 @@ import ru.fromchat.action_copy
import ru.fromchat.action_select import ru.fromchat.action_select
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.WebSocketManager 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.isSqliteBusy
import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.db.store.ConnectionStatus 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.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.app_name import ru.fromchat.app_name
import ru.fromchat.app_name_beta import ru.fromchat.app_name_beta
@@ -120,7 +120,19 @@ import kotlin.time.Duration.Companion.milliseconds
import org.jetbrains.skia.Image as SkiaImage import org.jetbrains.skia.Image as SkiaImage
private object DesktopApplicationBootstrap { 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 private var started = false
fun launchOnApplicationStart() { fun launchOnApplicationStart() {
@@ -261,6 +273,16 @@ private object DesktopProtocolRegistration {
} }
fun main(args: Array<String>) { 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() configureDesktopAppDataName()
// Do NOT set compose.layers.type=WINDOW. // Do NOT set compose.layers.type=WINDOW.
// On macOS Metal that mode creates a JWindow + MetalRedrawer per Popup/Dialog; native // 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. // Re-apply after AWT is fully up — Taskbar can ignore early sets on macOS :run.
SwingUtilities.invokeLater { applyDockIcon(dockIconImage) } SwingUtilities.invokeLater { applyDockIcon(dockIconImage) }
MessageDatabaseConcurrency.onSqliteBusy = { DatabaseLockGate.onSqliteBusy(it) }
Runtime.getRuntime().addShutdownHook(Thread { DatabaseLockGate.releaseRuntimeLock() })
MessageDatabaseRuntimeLock.tryAcquire()
application { application {
UtilsLibrary.init() 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) } var bootstrapStarted by remember { mutableStateOf(false) }
val databaseBlocked by DatabaseLockGate.blocked.collectAsState() val databaseBlocked by DatabaseLockGate.blocked.collectAsState()
val lockingProcesses by DatabaseLockGate.processes.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) { if (databaseBlocked) {
DatabaseLockWindow( DatabaseLockWindow(
appName = appName,
windowIcon = windowIcon,
dockIconImage = dockIconImage,
processes = lockingProcesses, processes = lockingProcesses,
onKillProcess = { DatabaseLockGate.killProcess(it) }, onKillProcess = { DatabaseLockGate.killProcess(it) },
onQuit = { exitApplication() }, onQuit = { exitApplication() },
) )
return@application
} }
val windowState = rememberWindowState( val windowState = rememberWindowState(
@@ -351,20 +379,12 @@ fun main(args: Array<String>) {
var contentReady by remember { mutableStateOf(false) } var contentReady by remember { mutableStateOf(false) }
var windowVisible by remember { mutableStateOf(true) } var windowVisible by remember { mutableStateOf(true) }
val trayState = rememberTrayState() val trayState = rememberTrayState()
val mac = remember { isMacOs() }
val windows = remember { isWindowsOs() }
val useTemplateTrayIcon = mac val useTemplateTrayIcon = mac
val trayIconImage = remember { loadAppIconBufferedImage(tray = useTemplateTrayIcon) } val trayIconImage = remember { loadAppIconBufferedImage(tray = useTemplateTrayIcon) }
val trayIcon = remember(trayIconImage) { val trayIcon = remember(trayIconImage) {
trayIconImage?.toPainter() ?: loadAppIconPainter(tray = useTemplateTrayIcon) trayIconImage?.toPainter() ?: loadAppIconPainter(tray = useTemplateTrayIcon)
} }
val windowIcon = remember {
dockIconImage?.toPainter() ?: loadAppIconPainter(tray = false)
}
val traySupported = remember { java.awt.SystemTray.isSupported() } 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 aboutApp = stringResource(Res.string.desktop_about_app)
val trayShow = stringResource(Res.string.desktop_tray_show) val trayShow = stringResource(Res.string.desktop_tray_show)
val quit = stringResource(Res.string.desktop_quit) val quit = stringResource(Res.string.desktop_quit)
@@ -564,7 +584,7 @@ fun main(args: Array<String>) {
onCloseRequest = { requestCloseToBackgroundOrQuit() }, onCloseRequest = { requestCloseToBackgroundOrQuit() },
title = appName, title = appName,
state = windowState, state = windowState,
visible = contentReady && (windowVisible || !traySupported) && !databaseBlocked, visible = contentReady && (windowVisible || !traySupported),
icon = windowIcon, icon = windowIcon,
undecorated = windows, undecorated = windows,
onPreviewKeyEvent = { event -> onPreviewKeyEvent = { event ->
@@ -632,7 +652,7 @@ fun main(args: Array<String>) {
} }
DisposableEffect(window) { DisposableEffect(window) {
installLoggedWindowExceptionHandler(window) installDesktopWindowExceptionHandler(window)
AppForeground.setForeground(true) AppForeground.setForeground(true)
fun syncWindowFocus(focused: Boolean) { fun syncWindowFocus(focused: Boolean) {
DesktopAppVisibility.isWindowFocused = focused DesktopAppVisibility.isWindowFocused = focused
@@ -714,9 +734,7 @@ fun main(args: Array<String>) {
.fillMaxSize() .fillMaxSize()
.background(windowChrome), .background(windowChrome),
) { ) {
if (!databaseBlocked) {
App(onContentReady = { contentReady = true }) App(onContentReady = { contentReady = true })
}
if (windows) { if (windows) {
MaterialTheme(colorScheme = getColorScheme(desktopAppDarkTheme(), dynamicColor = false)) { MaterialTheme(colorScheme = getColorScheme(desktopAppDarkTheme(), dynamicColor = false)) {
WindowsDesktopTitleBar( WindowsDesktopTitleBar(
@@ -868,15 +886,12 @@ private fun applyDesktopWindowChromeBackground() {
UIManager.put("control", ColorUIResource(desktopThemeBackgroundCompose().toAwtColor())) UIManager.put("control", ColorUIResource(desktopThemeBackgroundCompose().toAwtColor()))
} }
private fun desktopThemeBackgroundCompose() =
if (desktopAppDarkTheme()) Color(0xFF1C1B1F) else Color(0xFFFFFBFE)
private fun Color.toAwtColor() = private fun Color.toAwtColor() =
java.awt.Color(red, green, blue, alpha) java.awt.Color(red, green, blue, alpha)
private fun isSystemAppearanceDark() = desktopSystemDarkTheme() private fun isSystemAppearanceDark() = desktopSystemDarkTheme()
private fun applyDockIcon(image: BufferedImage?) { internal fun applyDockIcon(image: BufferedImage?) {
if (image == null) { if (image == null) {
Logger.w("DesktopIcon", "applyDockIcon skipped — image is null") Logger.w("DesktopIcon", "applyDockIcon skipped — image is null")
return return
@@ -1091,13 +1106,6 @@ private fun scaleBufferedImage(source: BufferedImage, size: Int): BufferedImage
return out 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. */ /** Opaque mark so Tray never substitutes the Compose default for a blank painter. */
private fun solidFallbackIcon(): BufferedImage { private fun solidFallbackIcon(): BufferedImage {
val out = BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB) 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() 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? { private fun extractExecutablePath(command: String): String? {
val trimmed = command.trim() val trimmed = command.trim()
if (trimmed.isEmpty()) return null if (trimmed.isEmpty()) return null
@@ -93,6 +93,8 @@ internal fun Window.windowsHwnd(): HWND {
internal fun HWND.isNativeZoomed(): Boolean = internal fun HWND.isNativeZoomed(): Boolean =
User32.INSTANCE.GetWindowLong(this, WinUser.GWL_STYLE) and WS_MAXIMIZE != 0 User32.INSTANCE.GetWindowLong(this, WinUser.GWL_STYLE) and WS_MAXIMIZE != 0
internal fun Window.isNativeZoomed(): Boolean = windowsHwnd().isNativeZoomed()
private const val WS_MAXIMIZE = 0x01000000 private const val WS_MAXIMIZE = 0x01000000
@Suppress("FunctionName") @Suppress("FunctionName")
@@ -2,14 +2,15 @@ package ru.fromchat.desktop
import com.sun.jna.Native import com.sun.jna.Native
import com.sun.jna.Structure import com.sun.jna.Structure
import com.sun.jna.WString
import com.sun.jna.platform.win32.WinBase 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.WinDef.DWORD
import com.sun.jna.platform.win32.WinNT import com.sun.jna.platform.win32.WinNT
import com.sun.jna.ptr.IntByReference import com.sun.jna.ptr.IntByReference
import com.sun.jna.win32.StdCallLibrary import com.sun.jna.win32.StdCallLibrary
import com.sun.jna.win32.W32APIOptions import com.sun.jna.win32.W32APIOptions
import java.io.File import java.io.File
import java.lang.ProcessHandle
internal object WindowsRestartManager { internal object WindowsRestartManager {
private const val ERROR_MORE_DATA = 234 private const val ERROR_MORE_DATA = 234
@@ -18,26 +19,36 @@ internal object WindowsRestartManager {
fun findLockingProcesses(files: List<File>): List<LockingProcessInfo> { fun findLockingProcesses(files: List<File>): List<LockingProcessInfo> {
if (!isWindowsOs()) return emptyList() 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() if (paths.isEmpty()) return emptyList()
val session = IntByReference() val session = IntByReference()
val sessionKey = CharArray(33) val sessionKey = CharArray(33)
val start = RestartManager.INSTANCE.RmStartSession(session, 0, sessionKey) 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 val sessionHandle = session.value
try { try {
val pathArray = paths.toTypedArray()
val register = RestartManager.INSTANCE.RmRegisterResources( val register = RestartManager.INSTANCE.RmRegisterResources(
sessionHandle, sessionHandle,
paths.size, pathArray.size,
paths.toTypedArray(), pathArray,
0, 0,
null, null,
0, 0,
null, 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 needed = IntByReference()
val count = IntByReference() val count = IntByReference()
@@ -48,10 +59,16 @@ internal object WindowsRestartManager {
null, null,
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 processCount = needed.value.coerceAtLeast(count.value)
val processes = Array(processCount) { RmProcessInfo() } if (processCount <= 0) return emptyList()
@Suppress("UNCHECKED_CAST")
val processes = RmProcessInfo().toArray(processCount) as Array<RmProcessInfo>
count.value = processCount count.value = processCount
result = RestartManager.INSTANCE.RmGetList( result = RestartManager.INSTANCE.RmGetList(
sessionHandle, sessionHandle,
@@ -60,13 +77,18 @@ internal object WindowsRestartManager {
processes, processes,
null, 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 return processes
.take(count.value.coerceAtMost(processes.size)) .take(count.value.coerceAtMost(processes.size))
.onEach { it.read() }
.mapNotNull { info -> .mapNotNull { info ->
val pid = info.dwProcessId.toLong() 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 appName = info.appName().trim()
val executable = ProcessExecutableResolver.executablePathForPid(pid) val executable = ProcessExecutableResolver.executablePathForPid(pid)
LockingProcessInfo( LockingProcessInfo(
@@ -93,7 +115,7 @@ internal object WindowsRestartManager {
"tsSessionId", "tsSessionId",
"restartable", "restartable",
) )
private class RmProcessInfo : Structure(), Structure.ByReference { private class RmProcessInfo : Structure() {
@JvmField var dwProcessId = 0 @JvmField var dwProcessId = 0
@JvmField var processStartTime = WinBase.FILETIME() @JvmField var processStartTime = WinBase.FILETIME()
@JvmField var strAppName = CharArray(CCH_RM_MAX_APP_NAME + 1) @JvmField var strAppName = CharArray(CCH_RM_MAX_APP_NAME + 1)
@@ -114,7 +136,7 @@ internal object WindowsRestartManager {
fileCount: Int, fileCount: Int,
fileNames: Array<String>?, fileNames: Array<String>?,
serviceCount: Int, serviceCount: Int,
serviceNames: Array<WString>?, serviceNames: Array<String>?,
processCount: Int, processCount: Int,
processIds: IntArray?, processIds: IntArray?,
): Int ): Int
@@ -42,15 +42,6 @@ internal fun clampFloatingAwtWindowToWorkArea(window: Window): Boolean {
return true return true
} }
internal fun applyFloatingGeometry(
window: Window,
windowState: WindowState,
geometry: DesktopFloatingGeometry,
density: Density,
) {
applyFloatingGeometry(window, windowState, geometry.size, geometry.position, density)
}
internal fun applyFloatingGeometry( internal fun applyFloatingGeometry(
window: Window, window: Window,
windowState: WindowState, windowState: WindowState,
@@ -80,7 +71,13 @@ internal fun applySavedFloatingGeometry(
windowState: WindowState, windowState: WindowState,
density: Density, density: Density,
) { ) {
applyFloatingGeometry(window, windowState, DesktopWindowPrefs.loadFloatingGeometry(), density) applyFloatingGeometry(
window,
windowState,
DesktopWindowPrefs.loadSize(),
DesktopWindowPrefs.loadPosition(),
density,
)
} }
internal fun captureFloatingGeometryToPrefs( internal fun captureFloatingGeometryToPrefs(
@@ -91,7 +88,7 @@ internal fun captureFloatingGeometryToPrefs(
if (windowState.placement != WindowPlacement.Floating || window.isNativeZoomed()) return if (windowState.placement != WindowPlacement.Floating || window.isNativeZoomed()) return
val bounds = window.bounds val bounds = window.bounds
with(density) { with(density) {
DesktopWindowPrefs.saveFloatingGeometry( DesktopWindowPrefs.save(
size = DpSize(bounds.width.toDp(), bounds.height.toDp()), size = DpSize(bounds.width.toDp(), bounds.height.toDp()),
position = WindowPosition(bounds.x.toDp(), bounds.y.toDp()), position = WindowPosition(bounds.x.toDp(), bounds.y.toDp()),
) )
+15
View File
@@ -2,8 +2,10 @@
import org.gradle.api.DefaultTask import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
@@ -26,6 +28,9 @@ abstract class GenerateAppBuildInfoTask : DefaultTask() {
@get:Input @get:Input
abstract val debugBuild: Property<Boolean> abstract val debugBuild: Property<Boolean>
@get:InputFile
abstract val releaseNotesFile: RegularFileProperty
@get:OutputDirectory @get:OutputDirectory
abstract val outputDirectory: DirectoryProperty abstract val outputDirectory: DirectoryProperty
@@ -35,6 +40,14 @@ abstract class GenerateAppBuildInfoTask : DefaultTask() {
check(outRoot.invariantSeparatorsPath.contains("/generated/")) { check(outRoot.invariantSeparatorsPath.contains("/generated/")) {
"AppBuildInfo must be written under a generated/ directory, got: $outRoot" "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.deleteRecursively()
outRoot.resolve("ru/fromchat").apply { mkdirs() }.resolve("AppBuildInfo.kt").writeText( 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 version = "${versionName.get()}"
| const val versionCode = ${versionCode.get()} | const val versionCode = ${versionCode.get()}
| const val isDebug = ${debugBuild.get()} | const val isDebug = ${debugBuild.get()}
| const val releaseNotesMarkdown = "$releaseNotesEscaped"
|} |}
| |
""".trimMargin() """.trimMargin()
@@ -55,6 +69,7 @@ abstract class GenerateAppBuildInfoTask : DefaultTask() {
val generateAppBuildInfo = tasks.register<GenerateAppBuildInfoTask>("generateAppBuildInfo") { val generateAppBuildInfo = tasks.register<GenerateAppBuildInfoTask>("generateAppBuildInfo") {
versionName.set(rootProject.extra["versionName"] as String) versionName.set(rootProject.extra["versionName"] as String)
versionCode.set(rootProject.extra["versionCode"] as Int) versionCode.set(rootProject.extra["versionCode"] as Int)
releaseNotesFile.set(rootProject.layout.projectDirectory.file("RELEASE_NOTES.md"))
debugBuild.set( debugBuild.set(
gradle.startParameter.taskNames.let { names -> gradle.startParameter.taskNames.let { names ->
!names.any { it.contains("Release", ignoreCase = true) } || !names.any { it.contains("Release", ignoreCase = true) } ||
@@ -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_website">Сайт</string>
<string name="about_link_privacy">Политика конфиденциальности</string> <string name="about_link_privacy">Политика конфиденциальности</string>
<string name="about_link_terms">Пользовательское соглашение</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_privacy_title">Политика конфиденциальности</string>
<string name="legal_terms_title">Пользовательское соглашение</string> <string name="legal_terms_title">Пользовательское соглашение</string>
<string name="legal_document_cached_banner">Показана сохранённая копия документа. Содержимое может быть устаревшим.</string> <string name="legal_document_cached_banner">Показана сохранённая копия документа. Содержимое может быть устаревшим.</string>
@@ -38,6 +42,7 @@
<string name="desktop_database_locked_kill">Завершить процесс</string> <string name="desktop_database_locked_kill">Завершить процесс</string>
<string name="desktop_database_locked_waiting">Ожидание освобождения базы данных…</string> <string name="desktop_database_locked_waiting">Ожидание освобождения базы данных…</string>
<string name="desktop_database_locked_unknown_process">Неизвестный процесс</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="desktop_database_locked_pid">PID %1$d</string>
<string name="welcome">Добро пожаловать!</string> <string name="welcome">Добро пожаловать!</string>
<string name="login">Войти</string> <string name="login">Войти</string>
@@ -15,6 +15,10 @@
<string name="about_link_website">Website</string> <string name="about_link_website">Website</string>
<string name="about_link_privacy">Privacy policy</string> <string name="about_link_privacy">Privacy policy</string>
<string name="about_link_terms">Terms of service</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_privacy_title">Privacy policy</string>
<string name="legal_terms_title">Terms of service</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> <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_kill">End process</string>
<string name="desktop_database_locked_waiting">Waiting for the database to become available…</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_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> <string name="desktop_database_locked_pid">PID %1$d</string>
<!-- Authentication --> <!-- Authentication -->
@@ -6,14 +6,17 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ChatListSync import ru.fromchat.api.ChatListSync
import ru.fromchat.api.ProfileUpdateSync import ru.fromchat.api.ProfileUpdateSync
import ru.fromchat.api.PublicChatProfileSync import ru.fromchat.api.PublicChatProfileSync
import ru.fromchat.api.StatusSubscriptionCoordinator import ru.fromchat.api.StatusSubscriptionCoordinator
import ru.fromchat.api.local.db.store.InstanceRegistryStore 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.MessageRepository
import ru.fromchat.api.local.db.store.PublicChatProfileCache 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.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.config.Settings import ru.fromchat.config.Settings
@@ -39,7 +42,12 @@ private fun scheduleAttachmentResumeAfterSession() {
private suspend fun activateInstance(instanceId: String) { private suspend fun activateInstance(instanceId: String) {
CacheContext.setActiveInstance(instanceId, ApiClient.user?.id) CacheContext.setActiveInstance(instanceId, ApiClient.user?.id)
withContext(messageDatabaseDispatcher) {
runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) } runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) }
if (instanceId.isNotBlank()) {
MessageDatabaseProvider.rebindUnboundPartition(instanceId)
}
}
PublicChatProfileSync.ensureStarted() PublicChatProfileSync.ensureStarted()
ProfileUpdateSync.ensureStarted() ProfileUpdateSync.ensureStarted()
StatusSubscriptionCoordinator.ensureStarted() StatusSubscriptionCoordinator.ensureStarted()
@@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache 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.api.local.cache.CacheContext.activeInstanceId
import ru.fromchat.ui.chat.utils.PublicChatPanelCache import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import ru.fromchat.ui.chat.panels.dm.DmPanelCache import ru.fromchat.ui.chat.panels.dm.DmPanelCache
@@ -28,12 +27,8 @@ object CacheContext {
_activeUserId.value = userId _activeUserId.value = userId
if (changed) { if (changed) {
ProfileCache.onActiveInstanceChanged(trimmed) ProfileCache.onActiveInstanceChanged(trimmed)
runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(trimmed) }
PublicChatPanelCache.onActiveInstanceChanged(trimmed) PublicChatPanelCache.onActiveInstanceChanged(trimmed)
DmPanelCache.onActiveInstanceChanged(trimmed) DmPanelCache.onActiveInstanceChanged(trimmed)
if (trimmed.isNotEmpty()) {
MessageDatabaseProvider.rebindUnboundPartition(trimmed)
}
} }
} }
@@ -1,7 +1,7 @@
package ru.fromchat.api.local.db package ru.fromchat.api.local.db
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.local.db.store.messageDatabaseDispatcher
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ChatListSync import ru.fromchat.api.ChatListSync
import ru.fromchat.api.ProfileUpdateSync import ru.fromchat.api.ProfileUpdateSync
@@ -36,7 +36,7 @@ suspend fun wipeLocalCacheOnDisk() {
if (instanceId.isNotEmpty()) { if (instanceId.isNotEmpty()) {
cancelOutboxProcessing(instanceId) cancelOutboxProcessing(instanceId)
} }
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
MessageDatabaseProvider.closeAndReset() MessageDatabaseProvider.closeAndReset()
} }
wipeFromChatCacheDirectory() wipeFromChatCacheDirectory()
@@ -0,0 +1,9 @@
package ru.fromchat.api.local.db
object MessageDatabaseConcurrency {
var onSqliteBusy: ((Throwable) -> Unit)? = null
fun notifySqliteBusy(throwable: Throwable) {
onSqliteBusy?.invoke(throwable)
}
}
@@ -270,7 +270,9 @@ private fun normalizeLegacyConversationIds(driver: SqlDriver) {
} }
private fun migrationInstanceId(): String { 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 if (known.isNotEmpty() && isValidInstanceUuid(known)) return known
return UNBOUND_MIGRATION_INSTANCE_ID return UNBOUND_MIGRATION_INSTANCE_ID
} }
@@ -14,25 +14,14 @@ fun isSqliteBusy(throwable: Throwable): Boolean {
return false return false
} }
internal fun <T> withSqliteBusyRetry( internal fun <T> withSqliteBusyGuard(block: () -> T): T {
maxAttempts: Int = 8,
initialDelayMs: Long = 25L,
block: () -> T,
): T {
var attempt = 0
var delayMs = initialDelayMs
while (true) {
try { try {
return block() return block()
} catch (e: Throwable) { } catch (e: Throwable) {
attempt++ if (isSqliteBusy(e)) {
if (!isSqliteBusy(e) || attempt >= maxAttempts) throw e Logger.w("MessageDatabase", "SQLite busy", e)
Logger.w( MessageDatabaseConcurrency.notifySqliteBusy(e)
"MessageDatabase", }
"SQLite busy (attempt $attempt/$maxAttempts), retrying in ${delayMs}ms", throw e
)
Thread.sleep(delayMs)
delayMs = minOf(delayMs * 2, 500L)
}
} }
} }
@@ -1,6 +1,5 @@
package ru.fromchat.api.local.db.store package ru.fromchat.api.local.db.store
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.local.send.cancelOutboxProcessing import ru.fromchat.api.local.send.cancelOutboxProcessing
import ru.fromchat.config.ServerConfigData import ru.fromchat.config.ServerConfigData
@@ -18,7 +17,7 @@ object InstanceRegistryStore {
private fun nowIso(): String = Clock.System.now().toString() private fun nowIso(): String = Clock.System.now().toString()
suspend fun getActiveInstanceIdForConfig(config: ServerConfigData): String? = suspend fun getActiveInstanceIdForConfig(config: ServerConfigData): String? =
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
MessageDatabaseProvider.withDatabaseRecover { MessageDatabaseProvider.withDatabaseRecover {
db.messageDatabaseQueries db.messageDatabaseQueries
.selectActiveInstanceIdForConfig(config.configKey()) .selectActiveInstanceIdForConfig(config.configKey())
@@ -30,7 +29,7 @@ object InstanceRegistryStore {
val id = instanceId.trim() val id = instanceId.trim()
if (id.isEmpty()) return if (id.isEmpty()) return
val now = nowIso() val now = nowIso()
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
MessageDatabaseProvider.withDatabaseRecover { MessageDatabaseProvider.withDatabaseRecover {
val existing = db.messageDatabaseQueries val existing = db.messageDatabaseQueries
.selectAllInstanceIds() .selectAllInstanceIds()
@@ -54,7 +53,7 @@ object InstanceRegistryStore {
} }
val key = config.configKey() val key = config.configKey()
val now = nowIso() val now = nowIso()
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
MessageDatabaseProvider.withDatabaseRecover { MessageDatabaseProvider.withDatabaseRecover {
db.messageDatabaseQueries.upsertServerBinding(key, newId, now) db.messageDatabaseQueries.upsertServerBinding(key, newId, now)
val existing = db.messageDatabaseQueries val existing = db.messageDatabaseQueries
@@ -93,7 +92,7 @@ object InstanceRegistryStore {
suspend fun purgePartition(instanceId: String) { suspend fun purgePartition(instanceId: String) {
val id = instanceId.trim() val id = instanceId.trim()
if (id.isEmpty()) return if (id.isEmpty()) return
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
MessageDatabaseProvider.withDatabaseRecover { MessageDatabaseProvider.withDatabaseRecover {
db.messageDatabaseQueries.deleteAllMessagesForInstance(id) db.messageDatabaseQueries.deleteAllMessagesForInstance(id)
db.messageDatabaseQueries.deleteAllConversationsForInstance(id) db.messageDatabaseQueries.deleteAllConversationsForInstance(id)
@@ -106,7 +105,7 @@ object InstanceRegistryStore {
} }
suspend fun purgeAllCache() { suspend fun purgeAllCache() {
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
MessageDatabaseProvider.withDatabaseRecover { MessageDatabaseProvider.withDatabaseRecover {
db.messageDatabaseQueries.purgeAllCache() db.messageDatabaseQueries.purgeAllCache()
} }
@@ -114,7 +113,7 @@ object InstanceRegistryStore {
} }
suspend fun clearServerBindingForCurrentConfig() { suspend fun clearServerBindingForCurrentConfig() {
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
MessageDatabaseProvider.withDatabaseRecover { MessageDatabaseProvider.withDatabaseRecover {
db.messageDatabaseQueries.deleteServerBinding(Settings.serverConfig.configKey()) db.messageDatabaseQueries.deleteServerBinding(Settings.serverConfig.configKey())
} }
@@ -87,9 +87,9 @@ object MessageCacheStore {
db.messageDatabaseQueries db.messageDatabaseQueries
.selectMessagesByConversation(instanceId, conversationId) .selectMessagesByConversation(instanceId, conversationId)
.asFlow() .asFlow()
.mapToList(Dispatchers.Default) .mapToList(messageDatabaseDispatcher)
.mapLatest { rows -> .mapLatest { rows ->
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
val raw = hydrateReplyReferencesFromRows(rows) val raw = hydrateReplyReferencesFromRows(rows)
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
val hydrated = hydrateAttachmentPreviewsFromDisk(withoutSuperseded) val hydrated = hydrateAttachmentPreviewsFromDisk(withoutSuperseded)
@@ -155,7 +155,7 @@ object MessageCacheStore {
suspend fun loadRecentPublicChatPreviewState( suspend fun loadRecentPublicChatPreviewState(
strings: ChatListPreviewStrings, strings: ChatListPreviewStrings,
limit: Long = 1, limit: Long = 1,
): ChatListPreviewState? = withContext(Dispatchers.Default) { ): ChatListPreviewState? = withContext(messageDatabaseDispatcher) {
val convId = conversationIdForPublic() val convId = conversationIdForPublic()
val iid = instanceId() val iid = instanceId()
val recent = resolvePreviewSourceMessageRow(iid, convId) val recent = resolvePreviewSourceMessageRow(iid, convId)
@@ -185,7 +185,7 @@ object MessageCacheStore {
dropSupersededOptimisticMessages(before, ApiClient.user?.id), dropSupersededOptimisticMessages(before, ApiClient.user?.id),
).let { sortMessagesForChatDisplay(it) } ).let { sortMessagesForChatDisplay(it) }
val iid = instanceId() val iid = instanceId()
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
purgeSupersededPendingRows(iid, convId, before, merged) purgeSupersededPendingRows(iid, convId, before, merged)
} }
replaceMessages(convId, merged, replaceAll = replaceAll) replaceMessages(convId, merged, replaceAll = replaceAll)
@@ -219,7 +219,7 @@ object MessageCacheStore {
).let { sortMessagesForChatDisplay(it) } ).let { sortMessagesForChatDisplay(it) }
val hydrated = hydrateReplyToInMemory(merged) val hydrated = hydrateReplyToInMemory(merged)
val iid = instanceId() val iid = instanceId()
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
purgeSupersededPendingRows(iid, convId, before, hydrated) purgeSupersededPendingRows(iid, convId, before, hydrated)
} }
replaceMessages(convId, hydrated, replaceAll = replaceAll) replaceMessages(convId, hydrated, replaceAll = replaceAll)
@@ -273,7 +273,7 @@ object MessageCacheStore {
val cid = clientMessageId.trim() val cid = clientMessageId.trim()
if (cid.isEmpty()) return if (cid.isEmpty()) return
val iid = instanceId() val iid = instanceId()
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries.updateMessageSendStatusByClientMessageId( db.messageDatabaseQueries.updateMessageSendStatusByClientMessageId(
sendStatus = "failed", sendStatus = "failed",
instanceId = iid, instanceId = iid,
@@ -287,7 +287,7 @@ object MessageCacheStore {
val cid = clientMessageId.trim() val cid = clientMessageId.trim()
if (cid.isEmpty()) return if (cid.isEmpty()) return
val iid = instanceId() val iid = instanceId()
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries.updateMessageSendStatusByClientMessageId( db.messageDatabaseQueries.updateMessageSendStatusByClientMessageId(
sendStatus = "pending", sendStatus = "pending",
instanceId = iid, instanceId = iid,
@@ -337,7 +337,7 @@ object MessageCacheStore {
} }
suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) { suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) {
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
var resolved = confirmed.resolvePublicAttachmentLayout() var resolved = confirmed.resolvePublicAttachmentLayout()
resolved = hydrateAttachmentPreviewFromDisk(resolved) resolved = hydrateAttachmentPreviewFromDisk(resolved)
ProfileCache.mergePreviewFromPublicMessage(resolved) ProfileCache.mergePreviewFromPublicMessage(resolved)
@@ -413,7 +413,7 @@ object MessageCacheStore {
if (messageId <= 0 || !DecryptedImageCache.isDecryptedImageCacheUri(localPreviewUri)) return if (messageId <= 0 || !DecryptedImageCache.isDecryptedImageCacheUri(localPreviewUri)) return
val convId = conversationIdForPublic() val convId = conversationIdForPublic()
val iid = instanceId() val iid = instanceId()
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
val row = db.messageDatabaseQueries val row = db.messageDatabaseQueries
.selectMessageById(iid, convId, messageId.toLong()) .selectMessageById(iid, convId, messageId.toLong())
.executeAsOneOrNull() ?: return@withContext .executeAsOneOrNull() ?: return@withContext
@@ -446,7 +446,7 @@ object MessageCacheStore {
if (messageId <= 0 || !DecryptedImageCache.isDecryptedImageCacheUri(localPreviewUri)) return if (messageId <= 0 || !DecryptedImageCache.isDecryptedImageCacheUri(localPreviewUri)) return
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
val iid = instanceId() val iid = instanceId()
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
val row = db.messageDatabaseQueries val row = db.messageDatabaseQueries
.selectMessageById(iid, convId, messageId.toLong()) .selectMessageById(iid, convId, messageId.toLong())
.executeAsOneOrNull() ?: return@withContext .executeAsOneOrNull() ?: return@withContext
@@ -475,7 +475,7 @@ object MessageCacheStore {
"MessageCache", "MessageCache",
"markMessageDeleted (soft) convId=$conversationId messageId=$messageId", "markMessageDeleted (soft) convId=$conversationId messageId=$messageId",
) )
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries.markMessageDeleted( db.messageDatabaseQueries.markMessageDeleted(
instanceId = iid, instanceId = iid,
id = messageId.toLong(), id = messageId.toLong(),
@@ -491,7 +491,7 @@ object MessageCacheStore {
previewStrings?.let { listPreviewStrings = it } previewStrings?.let { listPreviewStrings = it }
val iid = instanceId() val iid = instanceId()
val currentUserId = ApiClient.user?.id val currentUserId = ApiClient.user?.id
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
val upserts = conversations.map { conv -> val upserts = conversations.map { conv ->
val conversationId = conversationIdForDm(conv.user.id) val conversationId = conversationIdForDm(conv.user.id)
val displayLabel = when { val displayLabel = when {
@@ -583,7 +583,7 @@ object MessageCacheStore {
suspend fun pruneEmptyConversations() { suspend fun pruneEmptyConversations() {
val iid = instanceId() val iid = instanceId()
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries.transaction { db.messageDatabaseQueries.transaction {
pruneEmptyConversationsLocked(iid) pruneEmptyConversationsLocked(iid)
} }
@@ -597,7 +597,7 @@ object MessageCacheStore {
suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) { suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) {
val iid = instanceId() val iid = instanceId()
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
val existing = db.messageDatabaseQueries val existing = db.messageDatabaseQueries
.selectConversationById(iid, convId) .selectConversationById(iid, convId)
.executeAsOneOrNull() .executeAsOneOrNull()
@@ -644,7 +644,7 @@ object MessageCacheStore {
if (otherUserId <= 0) return if (otherUserId <= 0) return
val iid = instanceId() val iid = instanceId()
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
val existing = db.messageDatabaseQueries val existing = db.messageDatabaseQueries
.selectConversationById(iid, convId) .selectConversationById(iid, convId)
.executeAsOneOrNull() ?: run { .executeAsOneOrNull() ?: run {
@@ -703,7 +703,7 @@ object MessageCacheStore {
suspend fun markDmConversationReadLocally(otherUserId: Int, messageIds: List<Int>? = null) { suspend fun markDmConversationReadLocally(otherUserId: Int, messageIds: List<Int>? = null) {
val iid = instanceId() val iid = instanceId()
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
if (messageIds.isNullOrEmpty()) { if (messageIds.isNullOrEmpty()) {
db.messageDatabaseQueries.markAllInboundDmMessagesRead( db.messageDatabaseQueries.markAllInboundDmMessagesRead(
instanceId = iid, instanceId = iid,
@@ -730,7 +730,7 @@ object MessageCacheStore {
suspend fun selectUnreadPublicMessageIds(): List<Int> { suspend fun selectUnreadPublicMessageIds(): List<Int> {
val iid = instanceId() val iid = instanceId()
val convId = conversationIdForPublic() val convId = conversationIdForPublic()
return withContext(Dispatchers.Default) { return withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries db.messageDatabaseQueries
.selectUnreadPublicMessageIds(iid, convId) .selectUnreadPublicMessageIds(iid, convId)
.executeAsList() .executeAsList()
@@ -742,7 +742,7 @@ object MessageCacheStore {
if (messageId <= 0) return true if (messageId <= 0) return true
val iid = instanceId() val iid = instanceId()
val convId = conversationIdForPublic() val convId = conversationIdForPublic()
return withContext(Dispatchers.Default) { return withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries db.messageDatabaseQueries
.selectMessageById(iid, convId, messageId.toLong()) .selectMessageById(iid, convId, messageId.toLong())
.executeAsOneOrNull() .executeAsOneOrNull()
@@ -753,7 +753,7 @@ object MessageCacheStore {
suspend fun markPublicMessagesReadLocally(messageIds: List<Int>? = null) { suspend fun markPublicMessagesReadLocally(messageIds: List<Int>? = null) {
val iid = instanceId() val iid = instanceId()
val convId = conversationIdForPublic() val convId = conversationIdForPublic()
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
if (messageIds == null) { if (messageIds == null) {
db.messageDatabaseQueries.markPublicMessagesRead(iid, convId) db.messageDatabaseQueries.markPublicMessagesRead(iid, convId)
} else { } else {
@@ -767,7 +767,7 @@ object MessageCacheStore {
suspend fun archiveDmConversation(otherUserId: Int) { suspend fun archiveDmConversation(otherUserId: Int) {
val iid = instanceId() val iid = instanceId()
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries.updateConversationArchived( db.messageDatabaseQueries.updateConversationArchived(
archived = 1L, archived = 1L,
instanceId = iid, instanceId = iid,
@@ -780,7 +780,7 @@ object MessageCacheStore {
suspend fun deleteDmConversation(otherUserId: Int) { suspend fun deleteDmConversation(otherUserId: Int) {
val iid = instanceId() val iid = instanceId()
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
val messages = db.messageDatabaseQueries val messages = db.messageDatabaseQueries
.selectMessagesByConversation(iid, convId) .selectMessagesByConversation(iid, convId)
.executeAsList() .executeAsList()
@@ -806,7 +806,7 @@ object MessageCacheStore {
suspend fun purgePendingNotFromUser(userId: Int) { suspend fun purgePendingNotFromUser(userId: Int) {
val iid = instanceId() val iid = instanceId()
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
val foreign = db.messageDatabaseQueries val foreign = db.messageDatabaseQueries
.selectForeignPendingMessages(iid, userId.toLong()) .selectForeignPendingMessages(iid, userId.toLong())
.executeAsList() .executeAsList()
@@ -828,7 +828,7 @@ object MessageCacheStore {
suspend fun purgeAllPendingForInstance() { suspend fun purgeAllPendingForInstance() {
val iid = runCatching { instanceId() }.getOrNull()?.trim().orEmpty() val iid = runCatching { instanceId() }.getOrNull()?.trim().orEmpty()
if (iid.isEmpty()) return if (iid.isEmpty()) return
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
val pending = db.messageDatabaseQueries val pending = db.messageDatabaseQueries
.selectAllPendingMessagesForInstance(iid) .selectAllPendingMessagesForInstance(iid)
.executeAsList() .executeAsList()
@@ -856,7 +856,7 @@ object MessageCacheStore {
} }
suspend fun loadCachedDmConversations(): List<CachedConversation> = suspend fun loadCachedDmConversations(): List<CachedConversation> =
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
loadCachedDmConversationsRows(instanceId()) loadCachedDmConversationsRows(instanceId())
} }
@@ -893,19 +893,19 @@ object MessageCacheStore {
val conversationsFlow = db.messageDatabaseQueries val conversationsFlow = db.messageDatabaseQueries
.selectActiveDmConversationsForInstance(instanceId) .selectActiveDmConversationsForInstance(instanceId)
.asFlow() .asFlow()
.mapToList(Dispatchers.Default) .mapToList(messageDatabaseDispatcher)
val messagesFlow = db.messageDatabaseQueries val messagesFlow = db.messageDatabaseQueries
.selectMessagesForInstance(instanceId) .selectMessagesForInstance(instanceId)
.asFlow() .asFlow()
.mapToList(Dispatchers.Default) .mapToList(messageDatabaseDispatcher)
val pendingFlow = db.messageDatabaseQueries val pendingFlow = db.messageDatabaseQueries
.selectAllPendingMessagesForInstance(instanceId) .selectAllPendingMessagesForInstance(instanceId)
.asFlow() .asFlow()
.mapToList(Dispatchers.Default) .mapToList(messageDatabaseDispatcher)
val outboxFlow = db.messageDatabaseQueries val outboxFlow = db.messageDatabaseQueries
.selectPendingOutboxForInstance(instanceId) .selectPendingOutboxForInstance(instanceId)
.asFlow() .asFlow()
.mapToList(Dispatchers.Default) .mapToList(messageDatabaseDispatcher)
val notifierFlow = DmConversationListNotifier.events.map { Unit } val notifierFlow = DmConversationListNotifier.events.map { Unit }
return merge(conversationsFlow, messagesFlow, pendingFlow, outboxFlow, notifierFlow) return merge(conversationsFlow, messagesFlow, pendingFlow, outboxFlow, notifierFlow)
.mapLatest { loadCachedDmConversations() } .mapLatest { loadCachedDmConversations() }
@@ -920,15 +920,15 @@ object MessageCacheStore {
val messagesFlow = db.messageDatabaseQueries val messagesFlow = db.messageDatabaseQueries
.selectMessagesByConversation(instanceId, convId) .selectMessagesByConversation(instanceId, convId)
.asFlow() .asFlow()
.mapToList(Dispatchers.Default) .mapToList(messageDatabaseDispatcher)
val pendingFlow = db.messageDatabaseQueries val pendingFlow = db.messageDatabaseQueries
.selectAllPendingMessagesForInstance(instanceId) .selectAllPendingMessagesForInstance(instanceId)
.asFlow() .asFlow()
.mapToList(Dispatchers.Default) .mapToList(messageDatabaseDispatcher)
val outboxFlow = db.messageDatabaseQueries val outboxFlow = db.messageDatabaseQueries
.selectPendingOutboxForInstance(instanceId) .selectPendingOutboxForInstance(instanceId)
.asFlow() .asFlow()
.mapToList(Dispatchers.Default) .mapToList(messageDatabaseDispatcher)
return merge(messagesFlow, pendingFlow, outboxFlow) return merge(messagesFlow, pendingFlow, outboxFlow)
.mapLatest { loadRecentPublicChatPreviewState(strings) } .mapLatest { loadRecentPublicChatPreviewState(strings) }
} }
@@ -1007,7 +1007,7 @@ object MessageCacheStore {
private suspend fun syncDmConversationPreviewFromCache(otherUserId: Int) { private suspend fun syncDmConversationPreviewFromCache(otherUserId: Int) {
val iid = instanceId() val iid = instanceId()
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
var row = db.messageDatabaseQueries var row = db.messageDatabaseQueries
.selectConversationById(iid, convId) .selectConversationById(iid, convId)
.executeAsOneOrNull() .executeAsOneOrNull()
@@ -1057,7 +1057,7 @@ object MessageCacheStore {
if (envelopeId <= 0) return true if (envelopeId <= 0) return true
val iid = instanceId() val iid = instanceId()
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
return withContext(Dispatchers.Default) { return withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries db.messageDatabaseQueries
.selectMessageById(iid, convId, envelopeId.toLong()) .selectMessageById(iid, convId, envelopeId.toLong())
.executeAsOneOrNull() .executeAsOneOrNull()
@@ -1068,7 +1068,7 @@ object MessageCacheStore {
private suspend fun clearConversationMessages(conversationId: String) { private suspend fun clearConversationMessages(conversationId: String) {
val iid = instanceId() val iid = instanceId()
Logger.d("MessageCache", "clearConversationMessages convId=$conversationId") Logger.d("MessageCache", "clearConversationMessages convId=$conversationId")
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId) db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
} }
} }
@@ -1079,27 +1079,27 @@ object MessageCacheStore {
"MessageCache", "MessageCache",
"deleteByClientMessageId convId=$conversationId clientId=$clientMessageId", "deleteByClientMessageId convId=$conversationId clientId=$clientMessageId",
) )
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId) db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
} }
} }
private suspend fun deleteMessageById(conversationId: String, messageId: Int) { private suspend fun deleteMessageById(conversationId: String, messageId: Int) {
val iid = instanceId() val iid = instanceId()
val beforeCount = withContext(Dispatchers.Default) { val beforeCount = withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId) .selectMessagesByConversation(iid, conversationId)
.executeAsList() .executeAsList()
.size .size
} }
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries.deleteMessageById( db.messageDatabaseQueries.deleteMessageById(
instanceId = iid, instanceId = iid,
conversationId = conversationId, conversationId = conversationId,
id = messageId.toLong(), id = messageId.toLong(),
) )
} }
val afterCount = withContext(Dispatchers.Default) { val afterCount = withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId) .selectMessagesByConversation(iid, conversationId)
.executeAsList() .executeAsList()
@@ -1114,7 +1114,7 @@ object MessageCacheStore {
private suspend fun upsertSingle(conversationId: String, msg: Message) { private suspend fun upsertSingle(conversationId: String, msg: Message) {
val iid = instanceId() val iid = instanceId()
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
val existing = db.messageDatabaseQueries val existing = db.messageDatabaseQueries
.selectMessageById(iid, conversationId, msg.id.toLong()) .selectMessageById(iid, conversationId, msg.id.toLong())
.executeAsOneOrNull() .executeAsOneOrNull()
@@ -1143,7 +1143,7 @@ object MessageCacheStore {
!confirmed.files.isNullOrEmpty() -> encodePersistedPublicMessage(confirmed) !confirmed.files.isNullOrEmpty() -> encodePersistedPublicMessage(confirmed)
else -> confirmed.content else -> confirmed.content
} }
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries.transaction { db.messageDatabaseQueries.transaction {
val existing = db.messageDatabaseQueries val existing = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId) .selectMessagesByConversation(iid, conversationId)
@@ -1175,7 +1175,7 @@ object MessageCacheStore {
private suspend fun loadMessages(conversationId: String): List<Message> { private suspend fun loadMessages(conversationId: String): List<Message> {
val iid = instanceId() val iid = instanceId()
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(iid) OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(iid)
return withContext(Dispatchers.Default) { return withContext(messageDatabaseDispatcher) {
val rows = db.messageDatabaseQueries val rows = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId) .selectMessagesByConversation(iid, conversationId)
.executeAsList() .executeAsList()
@@ -1198,7 +1198,7 @@ object MessageCacheStore {
private suspend fun loadRecentMessages(conversationId: String, limit: Long): List<Message> { private suspend fun loadRecentMessages(conversationId: String, limit: Long): List<Message> {
val iid = instanceId() val iid = instanceId()
return withContext(Dispatchers.Default) { return withContext(messageDatabaseDispatcher) {
val rows = db.messageDatabaseQueries val rows = db.messageDatabaseQueries
.selectRecentMessagesByConversation(iid, conversationId, limit) .selectRecentMessagesByConversation(iid, conversationId, limit)
.executeAsList() .executeAsList()
@@ -1209,7 +1209,7 @@ object MessageCacheStore {
private suspend fun loadPendingMessages(conversationId: String): List<Message> { private suspend fun loadPendingMessages(conversationId: String): List<Message> {
val iid = instanceId() val iid = instanceId()
return withContext(Dispatchers.Default) { return withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries db.messageDatabaseQueries
.selectPendingMessagesByConversation(iid, conversationId) .selectPendingMessagesByConversation(iid, conversationId)
.executeAsList() .executeAsList()
@@ -1473,7 +1473,7 @@ object MessageCacheStore {
suspend fun clearAll() { suspend fun clearAll() {
Logger.d("MessageCache", "clearAll") Logger.d("MessageCache", "clearAll")
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries.purgeAllCache() db.messageDatabaseQueries.purgeAllCache()
} }
} }
@@ -1507,7 +1507,7 @@ object MessageCacheStore {
} }
val validated = CacheValidator.filterMessages(conversationId, messages, self) val validated = CacheValidator.filterMessages(conversationId, messages, self)
val iid = instanceId() val iid = instanceId()
val beforeCount = withContext(Dispatchers.Default) { val beforeCount = withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId) .selectMessagesByConversation(iid, conversationId)
.executeAsList() .executeAsList()
@@ -1518,7 +1518,7 @@ object MessageCacheStore {
"replaceMessages convId=$conversationId replaceAll=$replaceAll " + "replaceMessages convId=$conversationId replaceAll=$replaceAll " +
"incoming=${messages.size} validated=${validated.size} rowsBefore=$beforeCount", "incoming=${messages.size} validated=${validated.size} rowsBefore=$beforeCount",
) )
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
val existingRows = db.messageDatabaseQueries val existingRows = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId) .selectMessagesByConversation(iid, conversationId)
.executeAsList() .executeAsList()
@@ -1556,7 +1556,7 @@ object MessageCacheStore {
} }
} }
} }
val afterCount = withContext(Dispatchers.Default) { val afterCount = withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId) .selectMessagesByConversation(iid, conversationId)
.executeAsList() .executeAsList()
@@ -1577,7 +1577,7 @@ object MessageCacheStore {
val cid = clientMessageId.trim() val cid = clientMessageId.trim()
if (cid.isEmpty()) return false if (cid.isEmpty()) return false
val iid = instanceId() val iid = instanceId()
return withContext(Dispatchers.Default) { return withContext(messageDatabaseDispatcher) {
db.messageDatabaseQueries db.messageDatabaseQueries
.selectSentMessageIdByClientMessageId(iid, conversationId, cid) .selectSentMessageIdByClientMessageId(iid, conversationId, cid)
.executeAsOneOrNull() != null .executeAsOneOrNull() != null
@@ -1590,7 +1590,7 @@ object MessageCacheStore {
) )
suspend fun findMessageForAttachmentStorageKey(storageKey: String): AttachmentResumeTarget? = suspend fun findMessageForAttachmentStorageKey(storageKey: String): AttachmentResumeTarget? =
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
val key = storageKey.trim() val key = storageKey.trim()
if (key.isEmpty()) return@withContext null if (key.isEmpty()) return@withContext null
val iid = runCatching { instanceId() }.getOrNull() ?: return@withContext null val iid = runCatching { instanceId() }.getOrNull() ?: return@withContext null
@@ -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
@@ -4,7 +4,8 @@ import app.cash.sqldelight.db.SqlDriver
import ru.fromchat.api.local.db.ensureMessageDatabaseSchema import ru.fromchat.api.local.db.ensureMessageDatabaseSchema
import ru.fromchat.api.local.db.rebindUnboundMigrationInstance import ru.fromchat.api.local.db.rebindUnboundMigrationInstance
import ru.fromchat.api.local.db.withMessageDatabaseLock 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 import ru.fromchat.db.MessageDatabase
/** /**
@@ -34,14 +35,17 @@ object MessageDatabaseProvider {
} }
/** Runs [block] once; on a stale connection after cache wipe, resets and retries. */ /** Runs [block] once; on a stale connection after cache wipe, resets and retries. */
internal fun <T> withDatabaseRecover(block: () -> T): T = internal fun <T> withDatabaseRecover(block: () -> T): T {
withSqliteBusyRetry {
try { try {
block() return block()
} catch (e: Exception) { } catch (e: Exception) {
if (isSqliteBusy(e)) {
MessageDatabaseConcurrency.notifySqliteBusy(e)
throw e
}
if (!isStaleSqliteConnection(e)) throw e if (!isStaleSqliteConnection(e)) throw e
closeAndReset() closeAndReset()
block() return block()
} }
} }
@@ -1,6 +1,5 @@
package ru.fromchat.api.local.db.store package ru.fromchat.api.local.db.store
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.fromchat.api.schema.user.profile.UserProfile import ru.fromchat.api.schema.user.profile.UserProfile
@@ -12,7 +11,7 @@ object ProfileCacheStore {
encodeDefaults = true 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() val id = instanceId.trim()
if (id.isEmpty()) return@withContext null if (id.isEmpty()) return@withContext null
val raw = MessageDatabaseProvider.database.messageDatabaseQueries val raw = MessageDatabaseProvider.database.messageDatabaseQueries
@@ -24,7 +23,7 @@ object ProfileCacheStore {
suspend fun put(instanceId: String, profile: UserProfile) { suspend fun put(instanceId: String, profile: UserProfile) {
val id = instanceId.trim() val id = instanceId.trim()
if (id.isEmpty()) return if (id.isEmpty()) return
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
MessageDatabaseProvider.database.messageDatabaseQueries.upsertProfileCache( MessageDatabaseProvider.database.messageDatabaseQueries.upsertProfileCache(
instanceId = id, instanceId = id,
userId = profile.id.toLong(), userId = profile.id.toLong(),
@@ -36,7 +35,7 @@ object ProfileCacheStore {
suspend fun remove(instanceId: String, userId: Int) { suspend fun remove(instanceId: String, userId: Int) {
val id = instanceId.trim() val id = instanceId.trim()
if (id.isEmpty()) return if (id.isEmpty()) return
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
MessageDatabaseProvider.database.messageDatabaseQueries.deleteProfileCache( MessageDatabaseProvider.database.messageDatabaseQueries.deleteProfileCache(
instanceId = id, instanceId = id,
userId = userId.toLong(), userId = userId.toLong(),
@@ -45,7 +44,7 @@ object ProfileCacheStore {
} }
suspend fun loadAllForInstance(instanceId: String): Map<Int, UserProfile> = suspend fun loadAllForInstance(instanceId: String): Map<Int, UserProfile> =
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
val id = instanceId.trim() val id = instanceId.trim()
if (id.isEmpty()) return@withContext emptyMap() if (id.isEmpty()) return@withContext emptyMap()
MessageDatabaseProvider.database.messageDatabaseQueries MessageDatabaseProvider.database.messageDatabaseQueries
@@ -1,6 +1,5 @@
package ru.fromchat.api.local.db.store package ru.fromchat.api.local.db.store
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile 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) getImmediate(instanceId)
} }
suspend fun put(instanceId: String, profile: PublicChatProfile) { suspend fun put(instanceId: String, profile: PublicChatProfile) {
val id = instanceId.trim() val id = instanceId.trim()
if (id.isEmpty()) return if (id.isEmpty()) return
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
putImmediate(id, profile) putImmediate(id, profile)
} }
} }
@@ -45,7 +44,7 @@ object PublicChatProfileCacheStore {
suspend fun remove(instanceId: String) { suspend fun remove(instanceId: String) {
val id = instanceId.trim() val id = instanceId.trim()
if (id.isEmpty()) return if (id.isEmpty()) return
withContext(Dispatchers.Default) { withContext(messageDatabaseDispatcher) {
MessageDatabaseProvider.database.messageDatabaseQueries.deletePublicChatProfileForInstance(id) MessageDatabaseProvider.database.messageDatabaseQueries.deletePublicChatProfileForInstance(id)
} }
} }
@@ -4,9 +4,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.MessageDatabaseProvider 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.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.send.DmAttachmentOutboxPayload import ru.fromchat.api.local.send.DmAttachmentOutboxPayload
import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.local.send.OutgoingMessageCoordinator
@@ -50,9 +52,11 @@ object AttachmentTransferBootstrap {
} }
private suspend fun repairPendingAttachmentArtifacts(instanceId: String) { private suspend fun repairPendingAttachmentArtifacts(instanceId: String) {
val rows = MessageDatabaseProvider.database.messageDatabaseQueries val rows = withContext(messageDatabaseDispatcher) {
MessageDatabaseProvider.database.messageDatabaseQueries
.selectPendingOutboxForInstance(instanceId) .selectPendingOutboxForInstance(instanceId)
.executeAsList() .executeAsList()
}
for (row in rows) { for (row in rows) {
if ( if (
row.kind != OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT && row.kind != OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT &&
@@ -28,6 +28,8 @@ object Settings {
private const val HTTPS_ENABLED_KEY = "https_enabled" private const val HTTPS_ENABLED_KEY = "https_enabled"
private const val DEVICE_SESSIONS_CACHE_KEY = "device_sessions_cache_v1" 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_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 settings = PlatformSettings()
private val deviceSessionsJson = Json { ignoreUnknownKeys = true } private val deviceSessionsJson = Json { ignoreUnknownKeys = true }
@@ -183,8 +185,22 @@ object Settings {
runIO { settings.remove(DEVICE_SESSIONS_CACHE_KEY) } 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). */ /** Last known [ServerInstanceIdResponse.instanceId] from the configured API (empty until first fetch). */
var lastKnownServerInstanceId: String 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) } 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 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("- ") -> { trimmed.startsWith("- ") -> {
Row(modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) { Row(modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) {
Text( Text(
@@ -48,6 +48,7 @@ import com.pr0gramm3r101.utils.widthSizeClass
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull 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.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration 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.ProfileCache
import ru.fromchat.api.local.db.store.UserStatusStore import ru.fromchat.api.local.db.store.UserStatusStore
import ru.fromchat.api.local.messages.DmInboxCoordinator 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.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
import ru.fromchat.config.ServerConfig 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.DesktopMenuCommand
import ru.fromchat.desktop.DesktopMenuCommands import ru.fromchat.desktop.DesktopMenuCommands
import ru.fromchat.notifications.NotificationLaunchCoordinator import ru.fromchat.notifications.NotificationLaunchCoordinator
@@ -243,11 +247,13 @@ fun App(
var sessionLogoutRequired by remember { mutableStateOf(false) } var sessionLogoutRequired by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
kotlinx.coroutines.withContext(Dispatchers.Default) {
runCatching { ServerConfig.initialize() } runCatching { ServerConfig.initialize() }
withContext(messageDatabaseDispatcher) {
runCatching { ensureFromChatCacheGeneration() } runCatching { ensureFromChatCacheGeneration() }
runCatching { NetworkConnectivity.ensureStarted() }
runCatching { ApiClient.loadPersistedData() } runCatching { ApiClient.loadPersistedData() }
}
withContext(Dispatchers.Default) {
runCatching { NetworkConnectivity.ensureStarted() }
runCatching { UpdateSyncManager.initializeFromStorage(ApiClient.user?.id) } runCatching { UpdateSyncManager.initializeFromStorage(ApiClient.user?.id) }
Logger.i("App", "FromChat started") Logger.i("App", "FromChat started")
} }
@@ -487,6 +493,12 @@ fun App(
} }
val fullscreenImageController = remember { ChatFullscreenImageController() } val fullscreenImageController = remember { ChatFullscreenImageController() }
var showReleaseNotesPrompt by remember { mutableStateOf(false) }
LaunchedEffect(startDestination) {
if (startDestination != null && ReleaseNotesPrompt.shouldShowAutomatically()) {
showReleaseNotesPrompt = true
}
}
CompositionLocalProvider( CompositionLocalProvider(
LocalNavController provides navController, LocalNavController provides navController,
LocalDesktopChatsNavController provides LocalDesktopChatsNavController provides
@@ -758,6 +770,9 @@ fun App(
ChatFullscreenImageHost(Modifier.fillMaxSize()) ChatFullscreenImageHost(Modifier.fillMaxSize())
CallOverlay(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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.NewReleases
import androidx.compose.material.icons.outlined.Shield import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -18,6 +19,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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_terms
import ru.fromchat.about_link_website import ru.fromchat.about_link_website
import ru.fromchat.about_version import ru.fromchat.about_version
import ru.fromchat.about_what_changed
import ru.fromchat.app_desc import ru.fromchat.app_desc
import ru.fromchat.logo_square import ru.fromchat.logo_square
import ru.fromchat.legal.DocumentType import ru.fromchat.legal.DocumentType
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.BrandTitle 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_TELEGRAM = "https://t.me/fromchat_ch"
private const val URL_MAX = "https://maxgate.io/fromchat_ch" private const val URL_MAX = "https://maxgate.io/fromchat_ch"
@@ -60,6 +67,11 @@ fun AboutScreen() {
val scrollBehavior = rememberSettingsCollapsingScrollBehavior() val scrollBehavior = rememberSettingsCollapsingScrollBehavior()
val navController = LocalNavController.current val navController = LocalNavController.current
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
var showReleaseNotes by remember { mutableStateOf(false) }
if (showReleaseNotes) {
ReleaseNotesDialog(onDismiss = { showReleaseNotes = false })
}
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
@@ -125,6 +137,22 @@ fun AboutScreen() {
} }
Category(Modifier.padding(top = 16.dp)) { 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( ListItem(
headline = stringResource(Res.string.about_link_telegram), headline = stringResource(Res.string.about_link_telegram),
supportingText = URL_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.Devices
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Language 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.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -60,16 +56,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.utils.currentDeviceInfo
import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeInput
import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.blur.hazeBlur import dev.chrisbanes.haze.blur.hazeBlur
@@ -85,9 +78,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.Res 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.ApiClient
import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.schema.user.devices.DeviceSessionInfo import ru.fromchat.api.schema.user.devices.DeviceSessionInfo
@@ -127,156 +117,12 @@ private fun isUnreachableDevicesFetchError(error: Throwable): Boolean = when (er
else -> true 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 @Composable
private fun formatDeviceLastSeen(iso: String?): String { private fun formatDeviceLastSeen(iso: String?): String {
if (iso.isNullOrBlank()) return stringResource(Res.string.unknown) if (iso.isNullOrBlank()) return stringResource(Res.string.unknown)
return iso.replace("T", " ").take(19) 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) @OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun DeviceSessionDetailBottomSheet( private fun DeviceSessionDetailBottomSheet(
@@ -305,7 +151,7 @@ private fun DeviceSessionDetailBottomSheet(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
text = deviceHeadline(d, unknownDeviceLabel), text = deviceSessionHeadline(d, unknownDeviceLabel),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center textAlign = TextAlign.Center
@@ -535,7 +381,7 @@ fun DevicesScreen(onBack: () -> Unit) {
ListItem( ListItem(
headline = remember(device) { headline = remember(device) {
deviceHeadline(device, unknownDeviceLabel) deviceSessionHeadline(device, unknownDeviceLabel)
}, },
supportingText = stringResource( supportingText = stringResource(
Res.string.settings_devices_last_active, 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))
}
}
}
}
}
}
@@ -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 package ru.fromchat.api.local.db
internal actual fun <T> withMessageDatabaseLock(block: () -> T): T = internal actual fun <T> withMessageDatabaseLock(block: () -> T): T =
withSqliteBusyRetry { withSqliteBusyGuard {
synchronized(MessageDatabaseLock) { block() } synchronized(MessageDatabaseLock) { block() }
} }
@@ -0,0 +1,5 @@
package ru.fromchat.api.local.db.store
import kotlinx.coroutines.CoroutineDispatcher
internal actual val messageDatabaseDispatcher: CoroutineDispatcher = SingleThreadSqlDriver.dispatcher
@@ -6,23 +6,24 @@ import com.pr0gramm3r101.utils.files.PlatformFileSystem
import java.io.File import java.io.File
import ru.fromchat.db.MessageDatabase import ru.fromchat.db.MessageDatabase
actual fun provideMessageDatabaseDriver(): SqlDriver { actual fun provideMessageDatabaseDriver(): SqlDriver =
SingleThreadSqlDriver.run {
val dir = File(PlatformFileSystem.getAppCacheDirectory(), "fromchat") val dir = File(PlatformFileSystem.getAppCacheDirectory(), "fromchat")
dir.mkdirs() dir.mkdirs()
val dbFile = File(dir, "message_database.db") val dbFile = File(dir, "message_database.db")
val needsCreate = !dbFile.exists() val needsCreate = !dbFile.exists()
val jdbc = JdbcSqliteDriver( val jdbc = JdbcSqliteDriver(
"jdbc:sqlite:${dbFile.absolutePath}?busy_timeout=30000&journal_mode=WAL", "jdbc:sqlite:${dbFile.absolutePath}?busy_timeout=0&journal_mode=WAL",
) )
if (needsCreate) { if (needsCreate) {
MessageDatabase.Schema.create(jdbc) MessageDatabase.Schema.create(jdbc)
} }
configureJvmSqliteDriver(jdbc) configureJvmSqliteDriver(jdbc)
return SynchronizedRetryingSqlDriver(jdbc) SingleThreadSqlDriver(jdbc)
} }
private fun configureJvmSqliteDriver(driver: SqlDriver) { 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 journal_mode = WAL;", 0)
driver.execute(null, "PRAGMA synchronous = NORMAL;", 0) driver.execute(null, "PRAGMA synchronous = NORMAL;", 0)
} }
@@ -1,8 +1,6 @@
package ru.fromchat.api.local.db.store package ru.fromchat.api.local.db.store
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import com.pr0gramm3r101.utils.files.PlatformFileSystem import com.pr0gramm3r101.utils.files.PlatformFileSystem
import ru.fromchat.api.local.db.isSqliteBusy
import java.io.File import java.io.File
import java.io.RandomAccessFile import java.io.RandomAccessFile
import java.nio.channels.OverlappingFileLockException import java.nio.channels.OverlappingFileLockException
@@ -23,11 +21,28 @@ object MessageDatabasePaths {
).filter { it.exists() } ).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() val targets = lockTargetFiles()
if (targets.isEmpty()) return false if (targets.isEmpty()) return false
if (targets.any { isFileLockedExclusively(it) }) return true return targets.any { isFileLockedExclusively(it) }
return isSqliteWriteLocked(databaseFile())
} }
private fun isFileLockedExclusively(file: File): Boolean = private fun isFileLockedExclusively(file: File): Boolean =
@@ -43,16 +58,4 @@ object MessageDatabasePaths {
lock == null lock == null
} }
}.getOrDefault(true) }.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()
}
}
}
@@ -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
View File
@@ -9,8 +9,8 @@ plugins {
} }
/** Single source of truth for app version (APK + generated [AppBuildInfo]). */ /** Single source of truth for app version (APK + generated [AppBuildInfo]). */
extra["versionName"] = "1.1.4" extra["versionName"] = "1.2.0"
extra["versionCode"] = 114 extra["versionCode"] = 120
buildscript { buildscript {
repositories { repositories {