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