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