From 9f9a1f9874ffe6692acd65c644ba09acdf42b20c Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 13 Sep 2026 23:51:45 +0300 Subject: [PATCH] Fix SQLite thread safety, add "What's new", single source of truth for app version, improve database lock UI --- RELEASE_NOTES.md | 3 + .../ru/fromchat/desktop/DatabaseLockGate.kt | 75 ++++-- .../ru/fromchat/desktop/DatabaseLockWindow.kt | 246 ++++++++++-------- .../desktop/DatabaseLockingProcessResolver.kt | 19 +- .../desktop/DesktopWindowChromeSetup.kt | 118 +++++++++ .../main/kotlin/ru/fromchat/desktop/Main.kt | 84 +++--- .../desktop/MessageDatabaseRuntimeLock.kt | 47 ++++ .../desktop/ProcessExecutableResolver.kt | 26 -- .../fromchat/desktop/WindowsNativeChrome.kt | 2 + .../fromchat/desktop/WindowsRestartManager.kt | 48 +++- .../fromchat/desktop/WindowsWindowBounds.kt | 19 +- app/shared/build.gradle.kts | 15 ++ .../MessageDatabaseDispatchers.android.kt | 6 + .../composeResources/values-ru/strings.xml | 5 + .../composeResources/values/strings.xml | 5 + .../api/instance/SessionInstanceBootstrap.kt | 10 +- .../fromchat/api/local/cache/CacheContext.kt | 5 - .../fromchat/api/local/db/LocalCacheWipe.kt | 4 +- .../local/db/MessageDatabaseConcurrency.kt | 9 + .../local/db/MessageDatabaseSchemaGuard.kt | 4 +- .../api/local/db/SqliteConcurrency.kt | 27 +- .../local/db/store/InstanceRegistryStore.kt | 13 +- .../api/local/db/store/MessageCacheStore.kt | 98 +++---- .../db/store/MessageDatabaseDispatchers.kt | 10 + .../local/db/store/MessageDatabaseProvider.kt | 22 +- .../api/local/db/store/ProfileCacheStore.kt | 9 +- .../db/store/PublicChatProfileCacheStore.kt | 7 +- .../workers/AttachmentTransferBootstrap.kt | 10 +- .../kotlin/ru/fromchat/config/Settings.kt | 18 +- .../kotlin/ru/fromchat/legal/Markdown.kt | 20 ++ .../commonMain/kotlin/ru/fromchat/ui/App.kt | 21 +- .../fromchat/ui/main/settings/AboutScreen.kt | 28 ++ .../ui/main/settings/DevicesScreen.kt | 158 +---------- .../ru/fromchat/ui/release/ReleaseNotes.kt | 148 +++++++++++ .../store/MessageDatabaseDispatchers.ios.kt | 6 + .../api/local/db/MessageDatabaseLock.jvm.kt | 2 +- .../store/MessageDatabaseDispatchers.jvm.kt | 5 + .../db/store/MessageDatabaseDriver.jvm.kt | 29 ++- .../local/db/store/MessageDatabasePaths.kt | 37 +-- .../local/db/store/SingleThreadSqlDriver.kt | 79 ++++++ .../db/store/SynchronizedRetryingSqlDriver.kt | 60 ----- build.gradle.kts | 4 +- 42 files changed, 972 insertions(+), 589 deletions(-) create mode 100644 RELEASE_NOTES.md create mode 100644 app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowChromeSetup.kt create mode 100644 app/desktop/src/main/kotlin/ru/fromchat/desktop/MessageDatabaseRuntimeLock.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.android.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseConcurrency.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/release/ReleaseNotes.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.ios.kt create mode 100644 app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.jvm.kt create mode 100644 app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/SingleThreadSqlDriver.kt delete mode 100644 app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/SynchronizedRetryingSqlDriver.kt diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..d7419b5 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,3 @@ +- Наконец-то появилась десктопная версия FromChat! Теперь вы можете общаться прямо с вашего компьютера на Windows, Linux и macOS. Даже старые Mac на Intel поддерживаются. +- На Android и десктопе вы теперь можете напрямую перетаскивать файлы и картинки прямо в чат без необходимости долго их искать. +- Улучшен экран устройств: теперь более наглядно видно, на каких устройствах вы вошли в аккаунт. И иконки теперь везде одинаковые. \ No newline at end of file diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockGate.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockGate.kt index b2286eb..5f42c41 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockGate.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockGate.kt @@ -19,38 +19,73 @@ object DatabaseLockGate { private val _processes = MutableStateFlow>(emptyList()) val processes: StateFlow> = _processes.asStateFlow() + private val _runtimeSqliteBusy = MutableStateFlow(false) + private var monitorJob: Job? = null + fun onSqliteBusy(throwable: Throwable?): Boolean { + Logger.w("DatabaseLockGate", "SQLite busy", throwable) + _runtimeSqliteBusy.value = true + return refreshBlockedState() + } + fun startMonitoring(scope: CoroutineScope, onUnlocked: () -> Unit) { monitorJob?.cancel() monitorJob = scope.launch { - var everBlocked = false var bootstrapDelivered = false while (isActive) { - val locked = MessageDatabasePaths.isDatabaseLocked() - if (locked) { - everBlocked = true - _blocked.value = true - _processes.value = DatabaseLockingProcessResolver.findLockingProcesses( - MessageDatabasePaths.lockTargetFiles(), - ) - } else { - if (_blocked.value) { - Logger.i("DatabaseLockGate", "database unlocked; resuming startup") - } - _blocked.value = false - _processes.value = emptyList() - if (!bootstrapDelivered) { - bootstrapDelivered = true - onUnlocked() - } - if (!everBlocked) return@launch - if (!_blocked.value) return@launch + val blocked = refreshBlockedState() + if (!blocked && !bootstrapDelivered) { + bootstrapDelivered = true + onUnlocked() } + if (!blocked && bootstrapDelivered) return@launch delay(750.milliseconds) } } } fun killProcess(pid: Long): Boolean = DatabaseLockingProcessResolver.killProcess(pid) + + fun releaseRuntimeLock() { + MessageDatabaseRuntimeLock.release() + } + + private fun refreshBlockedState(): Boolean { + val externalProcesses = DatabaseLockingProcessResolver.findLockingProcesses( + MessageDatabasePaths.lockProbeFiles(), + ) + + if (!MessageDatabaseRuntimeLock.isHeld() && !MessageDatabaseRuntimeLock.tryAcquire()) { + Logger.w( + "DatabaseLockGate", + "Instance lock held by another process; blockers=$externalProcesses", + ) + _blocked.value = true + _processes.value = externalProcesses + return true + } + + if (externalProcesses.isNotEmpty()) { + Logger.w( + "DatabaseLockGate", + "Database files in use by other process(es): $externalProcesses", + ) + _blocked.value = true + _processes.value = externalProcesses + return true + } + + if (_runtimeSqliteBusy.value) { + Logger.i( + "DatabaseLockGate", + "SQLite busy with no external locker — treating as in-process contention, not blocking", + ) + _runtimeSqliteBusy.value = false + } + + _blocked.value = false + _processes.value = emptyList() + return false + } } diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockWindow.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockWindow.kt index 7237421..f7a8796 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockWindow.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockWindow.kt @@ -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, 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, onKillProcess: (Long) -> Unit, onQuit: () -> Unit, ) { - val unknownProcess = stringResource(Res.string.desktop_database_locked_unknown_process) Column( modifier = Modifier - .widthIn(min = 480.dp, max = 520.dp) - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .widthIn(max = 360.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(32.dp), - ) - Text( - text = stringResource(Res.string.desktop_database_locked_title), - style = MaterialTheme.typography.headlineSmall, - ) - } + ExpressiveIconFrame( + icon = Icons.Default.Warning, + containerSize = 56.dp, + iconSize = 28.dp, + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + materialPolygon = MaterialShapes.SoftBurst, + ) + Text( + text = stringResource(Res.string.desktop_database_locked_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) Text( text = stringResource(Res.string.desktop_database_locked_message), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) - if (processes.isEmpty()) { - LockingProcessRow( - process = LockingProcessInfo( - pid = -1L, - name = unknownProcess, - description = null, - executablePath = null, - icon = null, - ), - killEnabled = false, - onKill = {}, - ) - } else { - processes.forEach { process -> - LockingProcessRow( - process = process, - killEnabled = process.pid > 0L, - onKill = { onKillProcess(process.pid) }, - ) + + if (processes.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + processes.forEach { process -> + LockingProcessRow( + process = process, + onKill = { onKillProcess(process.pid) }, + ) + } } + } else { + Text( + text = stringResource(Res.string.desktop_database_locked_unknown_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) } + Row( - modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.Center, ) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(10.dp)) Text( text = stringResource(Res.string.desktop_database_locked_waiting), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Spacer(modifier = Modifier.height(4.dp)) - Row( + + TextCta( + onClick = onQuit, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, ) { - OutlinedButton(onClick = onQuit) { - Text(stringResource(Res.string.desktop_quit)) - } + Text( + text = stringResource(Res.string.desktop_quit), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) } } } @@ -146,64 +179,55 @@ private fun DatabaseLockContent( @Composable private fun LockingProcessRow( process: LockingProcessInfo, - killEnabled: Boolean, onKill: () -> Unit, ) { - Surface( - tonalElevation = 1.dp, - shape = MaterialTheme.shapes.medium, + Row( modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (process.icon != null) { - Image( - bitmap = process.icon, - contentDescription = null, - modifier = Modifier.size(40.dp), - ) - } else { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(modifier = Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { + if (process.icon != null) { + Image( + bitmap = process.icon, + contentDescription = null, + modifier = Modifier.size(36.dp), + ) + } else { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + modifier = Modifier.size(36.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = process.name, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + process.description?.let { description -> Text( - text = process.name, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) - process.description?.let { description -> - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - if (process.pid > 0L) { - Text( - text = stringResource(Res.string.desktop_database_locked_pid, process.pid.toInt()), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } - if (killEnabled) { - Spacer(modifier = Modifier.width(8.dp)) - Button(onClick = onKill) { - Text(stringResource(Res.string.desktop_database_locked_kill)) - } + if (process.pid > 0L) { + Text( + text = stringResource(Res.string.desktop_database_locked_pid, process.pid.toInt()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (process.pid > 0L) { + Spacer(modifier = Modifier.width(8.dp)) + ActionButton(onClick = onKill) { + Text(stringResource(Res.string.desktop_database_locked_kill)) } } } diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockingProcessResolver.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockingProcessResolver.kt index 360a10a..a101b23 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockingProcessResolver.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DatabaseLockingProcessResolver.kt @@ -6,17 +6,14 @@ import java.util.concurrent.TimeUnit object DatabaseLockingProcessResolver { fun findLockingProcesses(files: List): List { - 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 } diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowChromeSetup.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowChromeSetup.kt new file mode 100644 index 0000000..44e9d63 --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/DesktopWindowChromeSetup.kt @@ -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) diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt index 5c28951..7b6b784 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt @@ -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) { + 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) { // 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) { } } + 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) { 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) { 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) { } DisposableEffect(window) { - installLoggedWindowExceptionHandler(window) + installDesktopWindowExceptionHandler(window) AppForeground.setForeground(true) fun syncWindowFocus(focused: Boolean) { DesktopAppVisibility.isWindowFocused = focused @@ -714,9 +734,7 @@ fun main(args: Array) { .fillMaxSize() .background(windowChrome), ) { - if (!databaseBlocked) { - App(onContentReady = { contentReady = true }) - } + App(onContentReady = { contentReady = true }) if (windows) { MaterialTheme(colorScheme = getColorScheme(desktopAppDarkTheme(), dynamicColor = false)) { WindowsDesktopTitleBar( @@ -868,15 +886,12 @@ private fun applyDesktopWindowChromeBackground() { UIManager.put("control", ColorUIResource(desktopThemeBackgroundCompose().toAwtColor())) } -private fun desktopThemeBackgroundCompose() = - if (desktopAppDarkTheme()) Color(0xFF1C1B1F) else Color(0xFFFFFBFE) - private fun Color.toAwtColor() = java.awt.Color(red, green, blue, alpha) private fun isSystemAppearanceDark() = desktopSystemDarkTheme() -private fun applyDockIcon(image: BufferedImage?) { +internal fun applyDockIcon(image: BufferedImage?) { if (image == null) { Logger.w("DesktopIcon", "applyDockIcon skipped — image is null") return @@ -1091,13 +1106,6 @@ private fun scaleBufferedImage(source: BufferedImage, size: Int): BufferedImage return out } -@OptIn(ExperimentalComposeUiApi::class) -private fun installLoggedWindowExceptionHandler(window: androidx.compose.ui.awt.ComposeWindow) { - window.exceptionHandler = WindowExceptionHandler { throwable -> - Logger.e("DesktopWindow", "uncaught in composition", throwable) - } -} - /** Opaque mark so Tray never substitutes the Compose default for a blank painter. */ private fun solidFallbackIcon(): BufferedImage { val out = BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB) diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/MessageDatabaseRuntimeLock.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/MessageDatabaseRuntimeLock.kt new file mode 100644 index 0000000..68036f9 --- /dev/null +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/MessageDatabaseRuntimeLock.kt @@ -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") + } +} diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/ProcessExecutableResolver.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/ProcessExecutableResolver.kt index c4663e4..d06205b 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/ProcessExecutableResolver.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/ProcessExecutableResolver.kt @@ -52,32 +52,6 @@ internal object ProcessExecutableResolver { return SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() } - fun findFromChatProcesses(): List { - 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 diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsNativeChrome.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsNativeChrome.kt index 38cf794..f66773a 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsNativeChrome.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsNativeChrome.kt @@ -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") diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsRestartManager.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsRestartManager.kt index cb1f47a..6967ddd 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsRestartManager.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsRestartManager.kt @@ -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): List { 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 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?, serviceCount: Int, - serviceNames: Array?, + serviceNames: Array?, processCount: Int, processIds: IntArray?, ): Int diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsWindowBounds.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsWindowBounds.kt index 0c523d6..34b3e1f 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsWindowBounds.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/WindowsWindowBounds.kt @@ -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()), ) diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index f5283f0..5f75ac8 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -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 + @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("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) } || diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.android.kt new file mode 100644 index 0000000..a5c61ab --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.android.kt @@ -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 diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 83cb88b..dcb2df8 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -12,6 +12,10 @@ Сайт Политика конфиденциальности Пользовательское соглашение + Что изменилось в этом обновлении + Что нового + OK + Больше не показывать Политика конфиденциальности Пользовательское соглашение Показана сохранённая копия документа. Содержимое может быть устаревшим. @@ -38,6 +42,7 @@ Завершить процесс Ожидание освобождения базы данных… Неизвестный процесс + Не удалось определить процесс. Возможно, базу удерживает другое приложение (IDE, просмотрщик БД, антивирус или синхронизация папки с данными). Проверьте диспетчер задач на скрытый java.exe. PID %1$d Добро пожаловать! Войти diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index 82cb09c..4d1fda0 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -15,6 +15,10 @@ Website Privacy policy Terms of service + What changed in this update + What\'s new + OK + Don\'t show again Privacy policy Terms of service Showing a saved copy. Content may be out of date. @@ -43,6 +47,7 @@ End process Waiting for the database to become available… Unknown process + 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. PID %1$d diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt index 3098c1c..847929e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt @@ -6,14 +6,17 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import ru.fromchat.api.ApiClient import ru.fromchat.api.ChatListSync import ru.fromchat.api.ProfileUpdateSync import ru.fromchat.api.PublicChatProfileSync import ru.fromchat.api.StatusSubscriptionCoordinator import ru.fromchat.api.local.db.store.InstanceRegistryStore +import ru.fromchat.api.local.db.store.MessageDatabaseProvider import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.PublicChatProfileCache +import ru.fromchat.api.local.db.store.messageDatabaseDispatcher import ru.fromchat.api.local.download.AttachmentDownloadNotifier import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.config.Settings @@ -39,7 +42,12 @@ private fun scheduleAttachmentResumeAfterSession() { private suspend fun activateInstance(instanceId: String) { CacheContext.setActiveInstance(instanceId, ApiClient.user?.id) - runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) } + withContext(messageDatabaseDispatcher) { + runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) } + if (instanceId.isNotBlank()) { + MessageDatabaseProvider.rebindUnboundPartition(instanceId) + } + } PublicChatProfileSync.ensureStarted() ProfileUpdateSync.ensureStarted() StatusSubscriptionCoordinator.ensureStarted() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheContext.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheContext.kt index b8b12d5..3e0f344 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheContext.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheContext.kt @@ -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) - } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt index 00ad2dd..7cae258 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt @@ -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() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseConcurrency.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseConcurrency.kt new file mode 100644 index 0000000..96b1b87 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseConcurrency.kt @@ -0,0 +1,9 @@ +package ru.fromchat.api.local.db + +object MessageDatabaseConcurrency { + var onSqliteBusy: ((Throwable) -> Unit)? = null + + fun notifySqliteBusy(throwable: Throwable) { + onSqliteBusy?.invoke(throwable) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseSchemaGuard.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseSchemaGuard.kt index ee005cb..4f26cfb 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseSchemaGuard.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseSchemaGuard.kt @@ -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 } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/SqliteConcurrency.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/SqliteConcurrency.kt index 989e5cd..881091b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/SqliteConcurrency.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/SqliteConcurrency.kt @@ -14,25 +14,14 @@ fun isSqliteBusy(throwable: Throwable): Boolean { return false } -internal fun withSqliteBusyRetry( - maxAttempts: Int = 8, - initialDelayMs: Long = 25L, - block: () -> T, -): T { - var attempt = 0 - var delayMs = initialDelayMs - while (true) { - try { - return block() - } catch (e: Throwable) { - attempt++ - if (!isSqliteBusy(e) || attempt >= maxAttempts) throw e - Logger.w( - "MessageDatabase", - "SQLite busy (attempt $attempt/$maxAttempts), retrying in ${delayMs}ms", - ) - Thread.sleep(delayMs) - delayMs = minOf(delayMs * 2, 500L) +internal fun withSqliteBusyGuard(block: () -> T): T { + try { + return block() + } catch (e: Throwable) { + if (isSqliteBusy(e)) { + Logger.w("MessageDatabase", "SQLite busy", e) + MessageDatabaseConcurrency.notifySqliteBusy(e) } + throw e } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt index 1a99b18..7566428 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt @@ -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()) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt index 0b993bb..6e0c7f2 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt @@ -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? = 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 { 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? = 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 = - 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 { 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 { 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 { 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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.kt new file mode 100644 index 0000000..fd4ca5d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseProvider.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseProvider.kt index cfabc4d..5836621 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseProvider.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseProvider.kt @@ -4,7 +4,8 @@ import app.cash.sqldelight.db.SqlDriver import ru.fromchat.api.local.db.ensureMessageDatabaseSchema import ru.fromchat.api.local.db.rebindUnboundMigrationInstance import ru.fromchat.api.local.db.withMessageDatabaseLock -import ru.fromchat.api.local.db.withSqliteBusyRetry +import ru.fromchat.api.local.db.isSqliteBusy +import ru.fromchat.api.local.db.MessageDatabaseConcurrency import ru.fromchat.db.MessageDatabase /** @@ -34,16 +35,19 @@ object MessageDatabaseProvider { } /** Runs [block] once; on a stale connection after cache wipe, resets and retries. */ - internal fun withDatabaseRecover(block: () -> T): T = - withSqliteBusyRetry { - try { - block() - } catch (e: Exception) { - if (!isStaleSqliteConnection(e)) throw e - closeAndReset() - block() + internal fun withDatabaseRecover(block: () -> T): T { + try { + return block() + } catch (e: Exception) { + if (isSqliteBusy(e)) { + MessageDatabaseConcurrency.notifySqliteBusy(e) + throw e } + if (!isStaleSqliteConnection(e)) throw e + closeAndReset() + return block() } + } internal fun isStaleSqliteConnection(throwable: Throwable): Boolean { var current: Throwable? = throwable diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCacheStore.kt index a04b75d..52d6fa9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCacheStore.kt @@ -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 = - withContext(Dispatchers.Default) { + withContext(messageDatabaseDispatcher) { val id = instanceId.trim() if (id.isEmpty()) return@withContext emptyMap() MessageDatabaseProvider.database.messageDatabaseQueries diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCacheStore.kt index a6edc9a..c784666 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCacheStore.kt @@ -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) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt index 24f1539..71a1e88 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt @@ -4,9 +4,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import ru.fromchat.api.ApiClient import ru.fromchat.api.local.db.store.MessageDatabaseProvider +import ru.fromchat.api.local.db.store.messageDatabaseDispatcher import ru.fromchat.api.local.download.AttachmentDownloadNotifier import ru.fromchat.api.local.send.DmAttachmentOutboxPayload import ru.fromchat.api.local.send.OutgoingMessageCoordinator @@ -50,9 +52,11 @@ object AttachmentTransferBootstrap { } private suspend fun repairPendingAttachmentArtifacts(instanceId: String) { - val rows = MessageDatabaseProvider.database.messageDatabaseQueries - .selectPendingOutboxForInstance(instanceId) - .executeAsList() + val rows = withContext(messageDatabaseDispatcher) { + MessageDatabaseProvider.database.messageDatabaseQueries + .selectPendingOutboxForInstance(instanceId) + .executeAsList() + } for (row in rows) { if ( row.kind != OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT && diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/config/Settings.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/config/Settings.kt index 98c4cac..9724020 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/config/Settings.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/config/Settings.kt @@ -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) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/Markdown.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/Markdown.kt index 82cf4b1..8db3b4d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/Markdown.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/Markdown.kt @@ -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( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index baace52..0647c7e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -48,6 +48,7 @@ import com.pr0gramm3r101.utils.widthSizeClass import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.booleanOrNull @@ -71,6 +72,7 @@ import ru.fromchat.api.instance.scheduleSessionInstanceNetworkRefresh import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration +import ru.fromchat.api.local.db.store.messageDatabaseDispatcher import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.UserStatusStore import ru.fromchat.api.local.messages.DmInboxCoordinator @@ -78,6 +80,8 @@ import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData import ru.fromchat.config.ServerConfig +import ru.fromchat.ui.release.ReleaseNotesDialog +import ru.fromchat.ui.release.ReleaseNotesPrompt import ru.fromchat.desktop.DesktopMenuCommand import ru.fromchat.desktop.DesktopMenuCommands import ru.fromchat.notifications.NotificationLaunchCoordinator @@ -243,11 +247,13 @@ fun App( var sessionLogoutRequired by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - kotlinx.coroutines.withContext(Dispatchers.Default) { - runCatching { ServerConfig.initialize() } + runCatching { ServerConfig.initialize() } + withContext(messageDatabaseDispatcher) { runCatching { ensureFromChatCacheGeneration() } - runCatching { NetworkConnectivity.ensureStarted() } runCatching { ApiClient.loadPersistedData() } + } + withContext(Dispatchers.Default) { + runCatching { NetworkConnectivity.ensureStarted() } runCatching { UpdateSyncManager.initializeFromStorage(ApiClient.user?.id) } Logger.i("App", "FromChat started") } @@ -487,6 +493,12 @@ fun App( } val fullscreenImageController = remember { ChatFullscreenImageController() } + var showReleaseNotesPrompt by remember { mutableStateOf(false) } + LaunchedEffect(startDestination) { + if (startDestination != null && ReleaseNotesPrompt.shouldShowAutomatically()) { + showReleaseNotesPrompt = true + } + } CompositionLocalProvider( LocalNavController provides navController, LocalDesktopChatsNavController provides @@ -758,6 +770,9 @@ fun App( ChatFullscreenImageHost(Modifier.fillMaxSize()) CallOverlay(Modifier.fillMaxSize()) + if (showReleaseNotesPrompt) { + ReleaseNotesDialog(onDismiss = { showReleaseNotesPrompt = false }) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt index f56e26e..3b57aaa 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt @@ -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, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt index bffe195..df339d1 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt @@ -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): List { - 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, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/release/ReleaseNotes.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/release/ReleaseNotes.kt new file mode 100644 index 0000000..287c5f1 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/release/ReleaseNotes.kt @@ -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)) + } + } + } + } + } +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.ios.kt new file mode 100644 index 0000000..c243e7e --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.ios.kt @@ -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 diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.jvm.kt index 2ae5984..65db85a 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseLock.jvm.kt @@ -1,7 +1,7 @@ package ru.fromchat.api.local.db internal actual fun withMessageDatabaseLock(block: () -> T): T = - withSqliteBusyRetry { + withSqliteBusyGuard { synchronized(MessageDatabaseLock) { block() } } diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.jvm.kt new file mode 100644 index 0000000..aecf192 --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDispatchers.jvm.kt @@ -0,0 +1,5 @@ +package ru.fromchat.api.local.db.store + +import kotlinx.coroutines.CoroutineDispatcher + +internal actual val messageDatabaseDispatcher: CoroutineDispatcher = SingleThreadSqlDriver.dispatcher diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt index 2f58800..19ef61f 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabaseDriver.jvm.kt @@ -6,23 +6,24 @@ import com.pr0gramm3r101.utils.files.PlatformFileSystem import java.io.File import ru.fromchat.db.MessageDatabase -actual fun provideMessageDatabaseDriver(): SqlDriver { - val dir = File(PlatformFileSystem.getAppCacheDirectory(), "fromchat") - dir.mkdirs() - val dbFile = File(dir, "message_database.db") - val needsCreate = !dbFile.exists() - val jdbc = JdbcSqliteDriver( - "jdbc:sqlite:${dbFile.absolutePath}?busy_timeout=30000&journal_mode=WAL", - ) - if (needsCreate) { - MessageDatabase.Schema.create(jdbc) +actual fun provideMessageDatabaseDriver(): SqlDriver = + SingleThreadSqlDriver.run { + val dir = File(PlatformFileSystem.getAppCacheDirectory(), "fromchat") + dir.mkdirs() + val dbFile = File(dir, "message_database.db") + val needsCreate = !dbFile.exists() + val jdbc = JdbcSqliteDriver( + "jdbc:sqlite:${dbFile.absolutePath}?busy_timeout=0&journal_mode=WAL", + ) + if (needsCreate) { + MessageDatabase.Schema.create(jdbc) + } + configureJvmSqliteDriver(jdbc) + SingleThreadSqlDriver(jdbc) } - configureJvmSqliteDriver(jdbc) - return SynchronizedRetryingSqlDriver(jdbc) -} private fun configureJvmSqliteDriver(driver: SqlDriver) { - driver.execute(null, "PRAGMA busy_timeout = 30000;", 0) + driver.execute(null, "PRAGMA busy_timeout = 0;", 0) driver.execute(null, "PRAGMA journal_mode = WAL;", 0) driver.execute(null, "PRAGMA synchronous = NORMAL;", 0) } diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabasePaths.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabasePaths.kt index b25b84b..9ab432d 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabasePaths.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/MessageDatabasePaths.kt @@ -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 { + 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) } } diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/SingleThreadSqlDriver.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/SingleThreadSqlDriver.kt new file mode 100644 index 0000000..53ee6e7 --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/SingleThreadSqlDriver.kt @@ -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 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 = onDbThread { delegate.execute(identifier, sql, parameters, binders) } + + override fun executeQuery( + identifier: Int?, + sql: String, + mapper: (SqlCursor) -> QueryResult, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)?, + ): QueryResult = onDbThread { delegate.executeQuery(identifier, sql, mapper, parameters, binders) } + + override fun newTransaction(): QueryResult = 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() + private val executor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "fromchat-sqlite").apply { + isDaemon = true + dbThread.set(this) + } + } + + val dispatcher: CoroutineDispatcher = executor.asCoroutineDispatcher() + + fun run(block: () -> T): T { + if (Thread.currentThread() == dbThread.get()) { + return block() + } + return executor.submit(Callable(block)).get() + } + } +} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/SynchronizedRetryingSqlDriver.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/SynchronizedRetryingSqlDriver.kt deleted file mode 100644 index d472c41..0000000 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/api/local/db/store/SynchronizedRetryingSqlDriver.kt +++ /dev/null @@ -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 = synchronized(accessLock) { - withSqliteBusyRetry { delegate.execute(identifier, sql, parameters, binders) } - } - - override fun executeQuery( - identifier: Int?, - sql: String, - mapper: (SqlCursor) -> QueryResult, - parameters: Int, - binders: (SqlPreparedStatement.() -> Unit)?, - ): QueryResult = synchronized(accessLock) { - withSqliteBusyRetry { delegate.executeQuery(identifier, sql, mapper, parameters, binders) } - } - - override fun newTransaction(): QueryResult = 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) - } -} diff --git a/build.gradle.kts b/build.gradle.kts index b01c328..c24ea30 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 {