Merge branch 'master' into ep/parallel-tests

This commit is contained in:
Evgeny @ SimpleX Chat committed 2026-09-26 20:05:56 +00:00
commit 3855b55dd8
41 files changed
+944 -115

No files matched your search

@@ -22,11 +22,11 @@ struct GetStakeBanner: View {
Button(action: onTap) {
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 4) {
Text("Get a stake in SimpleX Chat!")
Text("Invest in SimpleX Chat!")
.font(.headline)
.foregroundColor(theme.colors.primary)
.lineLimit(2)
Text("Invest on Wefunder from $100")
Text("Crowdfunding on Wefunder")
.font(.subheadline)
.foregroundColor(theme.colors.onBackground)
.lineLimit(2)
@@ -890,7 +890,7 @@ struct GetStakeView: View {
var body: some View {
ZoomablePageView {
VStack(alignment: .leading, spacing: 18) {
Text(verbatim: "Get a stake in\nSimpleX Chat")
Text(verbatim: "Invest in\nSimpleX Chat")
.font(.largeTitle)
.bold()
.fixedSize(horizontal: false, vertical: true)
@@ -13,6 +13,8 @@ import SimpleXChat
let simplexTeamURL = URL(string: "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im")!
let simplexNewsURL = URL(string: "simplex:/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA?h=smp18.simplex.im")!
let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
@@ -462,6 +464,15 @@ struct SettingsView: View {
}
Section(header: Text("Contact").foregroundColor(theme.colors.secondary)) {
settingsRow("antenna.radiowaves.left.and.right", color: theme.colors.secondary) {
Button("Follow SimpleX Network News") {
dismiss()
DispatchQueue.main.async {
ChatModel.shared.appOpenUrl = simplexNewsURL
}
}
}
.disabled(chatModel.chatRunning != true)
settingsRow("number", color: theme.colors.secondary) {
Button("Send questions and ideas") {
dismiss()
@@ -100,12 +100,18 @@
<data android:host="smp4.simplexonflux.com" />
<data android:host="smp5.simplexonflux.com" />
<data android:host="smp6.simplexonflux.com" />
<data android:pathPrefix="/invitation" />
<data android:pathPrefix="/contact" />
<data android:pathPrefix="/a" />
<data android:pathPrefix="/c" />
<data android:pathPrefix="/g" />
<data android:pathPrefix="/i" />
<data android:path="/invitation" />
<data android:path="/contact" />
<data android:path="/a" />
<data android:path="/c" />
<data android:path="/g" />
<data android:path="/i" />
<data android:pathPrefix="/invitation/" />
<data android:pathPrefix="/contact/" />
<data android:pathPrefix="/a/" />
<data android:pathPrefix="/c/" />
<data android:pathPrefix="/g/" />
<data android:pathPrefix="/i/" />
</intent-filter>
<!-- Receive files from other apps -->
<intent-filter>
@@ -143,6 +143,7 @@ kotlin {
}
// For jSystemThemeDetector only
implementation("net.java.dev.jna:jna-platform:5.14.0")
implementation("com.github.oshi:oshi-core:6.4.13")
implementation("com.sshtools:two-slices:0.9.1")
implementation("org.slf4j:slf4j-simple:2.0.12")
implementation("uk.co.caprica:vlcj:4.8.3")
@@ -0,0 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.ui.graphics.drawscope.DrawScope
// Android blurs the bars with a RenderEffect on the layer itself, so this is never called there.
actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) = drawBar()
@@ -0,0 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.ui.graphics.drawscope.DrawScope
// Draws the bars' background blurred. The drawing to blur is given in bar coordinates, whatever size the blur runs at.
expect fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit)
@@ -1,18 +1,18 @@
package chat.simplex.common.platform
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntSize
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.KeyChangeEffect
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filter
import java.io.File
import kotlin.math.roundToInt
@Composable
expect fun Modifier.desktopOnExternalDrag(
@@ -58,20 +58,23 @@ fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState<B
}
}
@Composable
fun blurHidesMedia(enabled: Boolean, blurred: State<Boolean>): Boolean =
enabled && blurred.value && remember { appPrefs.privacyMediaBlurRadius.state }.value > 0
@Composable
fun Modifier.privacyBlur(
enabled: Boolean,
preview: ImageBitmap,
blurred: MutableState<Boolean> = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) },
scrollState: State<Boolean>,
onLongClick: () -> Unit = {}
): Modifier {
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
return if (enabled && blurred.value) {
this then Modifier.blur(
radiusX = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp,
radiusY = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp,
edgeTreatment = BlurredEdgeTreatment(RoundedCornerShape(0.dp))
)
return if (blurHidesMedia(enabled, blurred)) {
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
this then Modifier
.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
.combinedClickable(
onLongClick = onLongClick,
onClick = {
@@ -90,3 +93,25 @@ fun Modifier.privacyBlur(
this
}
}
// Calibrated so the resample blurs as much as Modifier.blur did at each radius; 360 read 5-75% stronger.
private const val BLURRED_MEDIA_WIDTH_DP = 400
// Bounds the first step: nothing bounds a decoded video frame, and reading every pixel of a 4K one would stall.
private const val RESAMPLE_MEDIA_FROM_SIDE = 512
private fun ImageBitmap.blurredBy(radius: Int): ImageBitmap {
if (width <= 0 || height <= 0) return this
val w = (BLURRED_MEDIA_WIDTH_DP / radius).coerceIn(1, width)
val h = (w * height / width).coerceIn(1, BLURRED_MEDIA_WIDTH_DP)
val longest = maxOf(width, height)
var image = if (longest > RESAMPLE_MEDIA_FROM_SIDE) {
val step = RESAMPLE_MEDIA_FROM_SIDE.toFloat() / longest
scale((width * step).roundToInt().coerceAtLeast(1), (height * step).roundToInt().coerceAtLeast(1))
} else this
// One bilinear step from a large image undersamples it.
while (image.width / 2 > w) image = image.scale(image.width / 2, (image.height / 2).coerceAtLeast(1))
image = image.scale(w, h)
// Stretching w px straight to the screen shows their grid; each doubling smooths it, so the last stretch is small.
while (maxOf(image.width, image.height) < BLURRED_MEDIA_WIDTH_DP / 2) image = image.scale(image.width * 2, image.height * 2)
return image
}
@@ -1205,12 +1205,13 @@ fun SynchronizeConnectionButtonForce(syncConnectionForce: () -> Unit) {
}
@Composable
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit, disabled: Boolean = false) {
SettingsActionItem(
if (contactVerified) painterResource(MR.images.ic_verified_user) else painterResource(MR.images.ic_shield),
stringResource(if (contactVerified) MR.strings.view_security_code else MR.strings.verify_security_code),
click = onClick,
iconColor = MaterialTheme.colors.secondary,
disabled = disabled,
)
}
@@ -499,6 +499,15 @@ fun ChatView(
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
hideKeyboard(view)
groupMembersJob.cancel()
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
val connectionLoaded = mutableStateOf(false)
if (chatsCtx.secondaryContextFilter == null) {
ModalManager.end.closeModals()
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close ->
GroupMemberInfoView(chatRh, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, close = close, closeAll = close)
}
groupMembersJob = scope.launch(Dispatchers.Default) {
val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId)
val stats = r?.second
@@ -513,15 +522,9 @@ fun ChatView(
withContext(Dispatchers.Main) {
chatModel.chatsContext.upsertGroupMember(chatRh, groupInfo, updatedMember)
}
if (chatsCtx.secondaryContextFilter == null) {
ModalManager.end.closeModals()
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close = close, closeAll = close)
}
}
connStats.value = stats
connectionCode.value = code
connectionLoaded.value = true
}
},
loadMessages = { chatId, pagination, visibleItemIndexes ->
@@ -123,6 +123,15 @@ fun ModalData.GroupChatInfoView(
}
},
showMemberInfo = { member, groupRelay ->
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
val connectionLoaded = mutableStateOf(false)
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
GroupMemberInfoView(rhId, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) {
closeCurrent()
close()
}
}
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId)
val stats = r?.second
@@ -132,14 +141,9 @@ fun ModalData.GroupChatInfoView(
} else {
member to null
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) {
closeCurrent()
close()
}
}
}
connStats.value = stats
connectionCode.value = code
connectionLoaded.value = true
}
},
editGroupProfile = {
@@ -45,10 +45,11 @@ import kotlinx.coroutines.*
fun GroupMemberInfoView(
rhId: Long?,
groupInfo: GroupInfo,
member: GroupMember,
groupMember: GroupMember,
scrollToItemId: MutableState<Long?>,
connectionStats: ConnectionStats?,
connectionCode: String?,
connStats: MutableState<ConnectionStats?>,
connectionCode: State<String?>,
connectionLoaded: State<Boolean>,
chatModel: ChatModel,
openedFromSupportChat: Boolean,
groupRelay: GroupRelay? = null,
@@ -60,7 +61,10 @@ fun GroupMemberInfoView(
}
BackHandler(onBack = close)
val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId }
val connStats = remember { mutableStateOf(connectionStats) }
// the passed member is shown until the loaded one is added to the model, so that the profile opens without waiting for the core
val member = remember(groupMember.groupMemberId) {
derivedStateOf { chatModel.getGroupMember(groupMember.groupMemberId) ?: groupMember }
}.value
val developerTools = chatModel.controller.appPrefs.developerTools.get()
var progressIndicator by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -91,7 +95,8 @@ fun GroupMemberInfoView(
connStats,
newRole,
developerTools,
connectionCode,
connectionCode.value,
connectionLoaded.value,
groupRelay = groupRelay,
getContactChat = { chatModel.getContactChat(it) },
openDirectChat = { contactId ->
@@ -101,6 +106,7 @@ fun GroupMemberInfoView(
}
},
createMemberContact = {
val connectionStats = connStats.value
if (member.sendMsgEnabled) {
withBGApi {
progressIndicator = true
@@ -209,7 +215,7 @@ fun GroupMemberInfoView(
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
VerifyCodeView(
mem.displayName,
connectionCode,
connectionCode.value,
mem.verified,
verify = { code ->
chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r ->
@@ -368,6 +374,7 @@ fun GroupMemberInfoLayout(
newRole: MutableState<GroupMemberRole>,
developerTools: Boolean,
connectionCode: String?,
connectionLoaded: Boolean,
groupRelay: GroupRelay? = null,
getContactChat: (Long) -> Chat?,
openDirectChat: (Long) -> Unit,
@@ -538,22 +545,23 @@ fun GroupMemberInfoLayout(
SectionDividerSpaced()
}
val memberConnected = member.memberActive || (groupInfo.useRelays && member.memberCurrent)
val showMemberSupportChat = !openedFromSupportChat &&
groupInfo.membership.memberRole >= GroupMemberRole.Moderator &&
member.memberRole != GroupMemberRole.Relay &&
((groupInfo.fullGroupPreferences.support.on && member.memberRole < GroupMemberRole.Moderator)
|| member.supportChat != null)
val canVerifyCode = connectionCode != null && member.memberRole != GroupMemberRole.Relay
// the same condition decides whether the code is requested, so the row is shown disabled until it arrives
val canVerifyCode = member.memberRole != GroupMemberRole.Relay && (connectionCode != null || !connectionLoaded)
val canSyncConn = cStats != null && cStats.ratchetSyncAllowed
if ((member.memberActive || (groupInfo.useRelays && member.memberCurrent))
&& (showMemberSupportChat || canVerifyCode || canSyncConn)) {
if (memberConnected && (showMemberSupportChat || canVerifyCode || canSyncConn)) {
SectionView {
if (showMemberSupportChat) {
SupportChatButton()
}
if (canVerifyCode) {
VerifyCodeButton(member.verified, verifyClicked)
VerifyCodeButton(member.verified, verifyClicked, disabled = connectionCode == null)
}
if (canSyncConn) {
SynchronizeConnectionButton(syncMemberConnection)
@@ -655,6 +663,20 @@ fun GroupMemberInfoLayout(
SimplexServers(stringResource(MR.strings.sending_via), sndServers)
}
}
} else if (!connectionLoaded && memberConnected) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) {
// the status is set from the same receiving queues as the servers below, so it is reserved with them
SectionItemView {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(MR.strings.network_status))
Icon(painterResource(MR.images.ic_info), stringResource(MR.strings.network_status), tint = MaterialTheme.colors.secondary)
}
}
SwitchAddressButton(disabled = true, switchAddress = {})
SimplexServers(stringResource(MR.strings.receiving_via), emptyList())
SimplexServers(stringResource(MR.strings.sending_via), emptyList())
}
}
val connFailedErr = member.activeConn?.connFailedErr
@@ -1117,6 +1139,7 @@ fun PreviewGroupMemberInfoLayout() {
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
developerTools = false,
connectionCode = "123",
connectionLoaded = true,
getContactChat = { Chat.sampleData },
openDirectChat = {},
createMemberContact = {},
@@ -76,6 +76,15 @@ fun MemberSupportChatAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
title = { MemberSupportChatToolbarTitle(scopeMember_) },
onTitleClick = {
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
val connectionLoaded = mutableStateOf(false)
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
GroupMemberInfoView(rhId, groupInfo, scopeMember_, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = true, close = closeCurrent) {
closeCurrent()
close()
}
}
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, scopeMember_.groupMemberId)
val stats = r?.second
@@ -85,14 +94,9 @@ fun MemberSupportChatAppBar(
} else {
null
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
remember { derivedStateOf { chatModel.getGroupMember(scopeMember_.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = true, close = closeCurrent) {
closeCurrent()
close()
}
}
}
connStats.value = stats
connectionCode.value = code
connectionLoaded.value = true
}
},
onTop = !oneHandUI.value || !chatBottomBar.value,
@@ -114,7 +114,7 @@ fun CIImageView(
onClick = onClick
)
.onRightClick { showMenu.value = true }
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
.privacyBlur(!smallView, imageBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
)
}
@@ -138,7 +138,7 @@ fun CIImageView(
onClick = onClick
)
.onRightClick { showMenu.value = true }
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
)
} else {
@@ -149,7 +149,7 @@ fun CIImageView(
onClick = {}
)
.onRightClick { showMenu.value = true }
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentAlignment = Alignment.Center
) {
imageView(previewBitmap, onClick = {
@@ -195,22 +195,26 @@ fun CIImageView(
contentAlignment = Alignment.TopEnd
) {
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
if (chatModel.connectedToRemote()) {
LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) {
withBGApi {
// Hidden media is not worth reading, decoding at full size and caching.
val revealed = !blurHidesMedia(!smallView, blurred)
if (revealed) {
if (chatModel.connectedToRemote()) {
LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) {
withBGApi {
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
res.value = imageAndFilePath(file)
}
}
}
} else {
LaunchedEffect(file) {
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
res.value = imageAndFilePath(file)
res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) }
}
}
}
} else {
LaunchedEffect(file) {
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) }
}
}
}
val loaded = res.value
val loaded = if (revealed) res.value else null
if (loaded != null && file != null) {
val (imageBitmap, data, _) = loaded
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) })
@@ -421,7 +421,7 @@ fun VideoPreviewImageView(
onClick = onClick
)
.onRightClick(onLongClick)
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick),
.privacyBlur(!smallView, preview, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick),
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
)
}
@@ -779,7 +779,7 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
} else {
withBGApi {
chatModel.appOpenUrlConnecting.value = true
planAndConnect(rhId, uri, close = null, cleanup = { chatModel.appOpenUrlConnecting.value = false })
planAndConnect(rhId, uri, close = { ModalManager.closeAllModalsEverywhere() }, cleanup = { chatModel.appOpenUrlConnecting.value = false })
}
}
}
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.drawBarsBlurred
import chat.simplex.common.ui.theme.CurrentColors
fun Modifier.blurredBackgroundModifier(
@@ -32,7 +33,7 @@ fun Modifier.blurredBackgroundModifier(
return if (appPlatform.isAndroid) {
this.androidBlurredModifier(keyboardInset, blurRadius.value, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
} else {
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, handler, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
}
}
@@ -100,40 +101,44 @@ private fun Modifier.desktopBlurredModifier(
blurRadius: State<Int>,
keyboardCoversBar: Boolean,
onTop: Boolean,
handler: AppBarHandler,
graphicsLayer: GraphicsLayer,
backgroundGraphicsLayer: GraphicsLayer,
backgroundGraphicsLayerSize: State<IntSize>,
density: Density
): Modifier = this
.graphicsLayer {
renderEffect = if (blurRadius.value > 0) BlurEffect(blurRadius.value.dp.toPx(), blurRadius.value.dp.toPx()) else null
clip = blurRadius.value > 0
}
.drawBehind {
drawRect(CurrentColors.value.colors.background)
if (onTop) {
clipRect {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
val barWidth = size.width
val barHeight = size.height
// The blur is taken from a copy of the scrolled content rather than drawn from that layer, so unlike a layer's own
// filter it does not follow the content on its own: the bar is redrawn when the container reports the copy moved.
handler.contentVersion.value
drawBarsBlurred(blurRadius.value.dp.toPx(), barWidth, barHeight) {
drawRect(CurrentColors.value.colors.background, size = Size(barWidth, barHeight))
if (onTop) {
clipRect(0f, 0f, barWidth, barHeight) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
drawLayer(graphicsLayer)
}
} else {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translate(top = -bgSize + size.height + keyboardHeightCovered) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
} else {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translate(top = -bgSize + barHeight + keyboardHeightCovered) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
drawLayer(graphicsLayer)
}
}
}
@@ -78,6 +78,10 @@ class AppBarHandler(
val backgroundGraphicsLayerSize: MutableState<IntSize> = mutableStateOf(IntSize.Zero)
// Bars that blur a copy of the scrolled content depend on this to know the copy moved. It is bumped from a collector
// rather than while drawing: a write made during the draw phase invalidates the bars every frame and never settles.
val contentVersion: MutableState<Int> = mutableStateOf(0)
companion object {
var appBarMaxHeightPx: Int = 0
}
@@ -194,7 +194,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel
@Composable
fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongClick: () -> Unit) {
val image = base64ToBitmap(linkPreview.image)
val image = remember(linkPreview.image) { base64ToBitmap(linkPreview.image) }
Column(
Modifier
.layoutId(CHAT_IMAGE_LAYOUT_ID)
@@ -207,7 +207,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongC
modifier = Modifier
.fillMaxWidth()
.desktopModifyBlurredState(true, blurred, showMenu)
.privacyBlur(true, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
.privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
contentScale = ContentScale.FillWidth,
)
Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) {
@@ -1033,7 +1033,7 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean {
return v != lastVersion
}
private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat"
private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat?utm_source=app"
private const val CROWDFUNDING_CONTACT_URI = "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im"
@@ -1197,7 +1197,7 @@ fun GetStakeView(showFirstImage: Boolean, inCenterOfWindow: Boolean = false, clo
ColumnWithScrollBar(Modifier.pinchZoom().padding(horizontal = DEFAULT_PADDING)) {
// in the center of the window the page is wide enough for the title to fit on one line
val title = "Get a stake in\nSimpleX Chat"
val title = "Invest in\nSimpleX Chat"
AppBarTitle(if (inCenterOfWindow) title.replace("\n", " ") else title, withPadding = false)
// What's new already shows the image of the first slide, above the link that opens this page
if (showFirstImage) {
@@ -74,6 +74,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: (
val simplexTeamUri =
"simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im"
val simplexNewsUri =
"simplex:/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA?h=smp18.simplex.im"
@Composable
fun SettingsLayout(
stopped: Boolean,
@@ -161,6 +164,7 @@ fun HelpAndSupportView(
SectionView(stringResource(MR.strings.settings_section_title_contact)) {
if (!chatModel.desktopNoUserNoRemote) {
SettingsActionItem(painterResource(MR.images.ic_bigtop_updates), stringResource(MR.strings.follow_simplex_network_news), { uriHandler.openVerifiedSimplexUri(simplexNewsUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
}
SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
@@ -1015,6 +1015,7 @@
<string name="markdown_in_messages">Markdown in messages</string>
<string name="chat_with_the_founder">Send questions and ideas</string>
<string name="send_us_an_email">Send us email</string>
<string name="follow_simplex_network_news">Follow SimpleX Network News</string>
<string name="chat_lock">SimpleX Lock</string>
<string name="chat_console">Chat console</string>
<string name="message_servers">Message servers</string>
@@ -2678,8 +2679,8 @@
<string name="v7_0_invest_descr" translatable="false">Crowdfunding on Wefunder.</string>
<string name="v7_0_crowdfunding" translatable="false">Crowdfunding on Wefunder</string>
<string name="v7_0_invest_learn_more" translatable="false">Learn more on Wefunder</string>
<string name="invest_banner_title" translatable="false">Get a stake in SimpleX Chat!</string>
<string name="invest_banner_subtitle" translatable="false">Invest on Wefunder from $100</string>
<string name="invest_banner_title" translatable="false">Invest in SimpleX Chat!</string>
<string name="invest_banner_subtitle" translatable="false">Crowdfunding on Wefunder</string>
<string name="v7_0_simplex_names">SimpleX public names (BETA)</string>
<string name="v7_0_simplex_names_descr">Public names for your channel or business.</string>
<string name="v7_0_channels">Better channels 📢</string>
@@ -76,12 +76,11 @@ internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVid
override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat {
// libvlc passes the size the decoder padded the picture to, not the size of the picture (dav1d
// pads to a multiple of 128, so 1920x1080 arrives as 1920x1152), and vlc stretches the picture to
// fill whatever size is returned. Ask for the size of the track being played instead. The format
// is negotiated more than once, and vlc has not selected the track yet on the first calls
val player = mediaPlayer
val tracks = player?.media()?.info()?.videoTracks()
val playingTrack = player?.video()?.track()
val track = tracks?.firstOrNull { it.id() == playingTrack } ?: tracks?.singleOrNull()
// fill whatever size is returned. Ask for the size of the video track instead. Not the selected
// track: video().track() takes the input lock, which stop() holds while it waits for this thread
// to finish, so asking here deadlocks both, and every caller of the player after them
val tracks = mediaPlayer?.media()?.info()?.videoTracks()
val track = tracks?.singleOrNull()
// Both track sides or neither: one side from the track and the other from the padded size libvlc
// passed never described the same picture, and transposing such a pair compounds the mismatch
val trackW = track?.width() ?: 0
@@ -0,0 +1,95 @@
package chat.simplex.common.platform
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.asComposeCanvas
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.Density
import org.jetbrains.skia.*
import kotlin.math.ceil
// Skia's raster blur costs width x (bar + 6 sigma) pixels every frame, which is what makes the bars lag. The blur cannot be
// made cheaper by scaling the layer it runs on, because a layer's filter is evaluated in device space: any scale applied to
// the layer scales the sigma straight back up, which weakens the blur without saving anything. So the bar is drawn into a
// narrower offscreen surface and blurred there, at its own resolution, and only the finished blur is scaled back out.
// Only the width is reduced: a bar is just AppBarHeight tall, and taking rows away from it visibly weakens the blur.
private const val MAX_BLUR_NARROWING = 8
private const val MIN_BLUR_WIDTH = 128
// How small the blur's own sigma is allowed to get. Below about this the narrowed copy is barely blurred, and stretching it
// back out shows the steps of the narrowing instead of a blur.
private const val MIN_NARROWED_SIGMA = 1.5f
// The rows are halved as well, with the vertical sigma halved to match, which is only sound because the copy is drawn at
// that size rather than scaled afterwards. A bar has few rows to begin with, so it is not reduced past this.
private const val MIN_BLUR_HEIGHT = 24
// The panes of the desktop window have different widths, so bars of several sizes are drawn in the same frame and a single
// pair of surfaces would be reallocated for each of them. Keeping one pair per size, with the least recently drawn size
// released once there are more than the window can show at once, allocates each pair once instead.
private const val MAX_BAR_SURFACES = 4
private class BarBlurSurface(width: Int, height: Int) {
val surface: Surface = Surface.makeRaster(ImageInfo.makeN32Premul(width, height))
fun close() = surface.close()
}
private val barBlurSurfaces = ThreadLocal.withInitial { LinkedHashMap<Long, BarBlurSurface>() }
private fun surfaceFor(width: Int, height: Int): BarBlurSurface {
val cached = barBlurSurfaces.get()
val key = (width.toLong() shl 32) or height.toLong()
val surface = cached.remove(key) ?: BarBlurSurface(width, height)
cached[key] = surface
while (cached.size > MAX_BAR_SURFACES) {
cached.remove(cached.keys.first())?.close()
}
return surface
}
private fun narrowing(sigma: Float, barWidth: Float): Int {
var scale = 1
while (sigma / (scale * 2) >= MIN_NARROWED_SIGMA && scale < MAX_BLUR_NARROWING && barWidth / (scale * 2) >= MIN_BLUR_WIDTH) scale *= 2
return scale
}
private fun shortening(sigma: Float, barHeight: Float): Int =
if (sigma / 2 >= MIN_NARROWED_SIGMA && barHeight / 2 >= MIN_BLUR_HEIGHT) 2 else 1
actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) {
val sigma = BlurEffect.convertRadiusToSigma(radiusPx)
val scale = narrowing(sigma, barWidth)
val rows = shortening(sigma, barHeight)
val width = ceil(barWidth / scale).toInt()
val height = ceil(barHeight / rows).toInt()
if (width <= 0 || height <= 0) return
val surface = surfaceFor(width, height).surface
surface.canvas.clear(Color.TRANSPARENT)
// the blur is the layer's own paint, so it runs at this surface's resolution rather than at the canvas the bar draws to
surface.canvas.saveLayer(Rect.makeWH(width.toFloat(), height.toFloat()), Paint().apply {
imageFilter = ImageFilter.makeBlur(sigma / scale, sigma / rows, FilterTileMode.CLAMP)
})
CanvasDrawScope().draw(Density(density, fontScale), layoutDirection, surface.canvas.asComposeCanvas(), Size(width.toFloat(), height.toFloat())) {
scale(1f / scale, 1f / rows, Offset.Zero) { drawBar() }
}
surface.canvas.restore()
// The surface is whole pixels while the bar is not, so only the bar's own area is taken from it.
drawIntoCanvas {
it.nativeCanvas.drawImageRect(
surface.makeImageSnapshot(),
Rect.makeWH(barWidth / scale, barHeight / rows),
Rect.makeWH(barWidth, barHeight),
SamplingMode.LINEAR,
null,
true
)
}
}
@@ -64,6 +64,9 @@ actual fun LazyColumnWithScrollBar(
}
val state = state ?: handler.listState
val connection = handler.connection
LaunchedEffect(state, handler) {
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }.collect { handler.contentVersion.value++ }
}
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on lazy column state
// (only first visible row is useful because LazyColumn doesn't have absolute scroll position, only relative to row)
val scrollBarDraggingState = remember { mutableStateOf(false) }
@@ -202,6 +205,9 @@ actual fun ColumnWithScrollBar(
}
}
val state = state ?: handler.scrollState
LaunchedEffect(state, handler) {
snapshotFlow { state.value }.collect { handler.contentVersion.value++ }
}
val connection = handler.connection
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on column state
// (exact scroll position is available but in Int, not Float)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 171 KiB

+126
View File
@@ -0,0 +1,126 @@
# Blur media by resampling the preview
## Problem
"Blur media" is a per-frame effect. Every frame a blurred image or video preview is drawn, a
Gaussian is convolved over an offscreen layer the size of the drawn media - about 500.dp wide, so
roughly 1500x1125 px and a 6 MB render target on a 3x-density phone, for each blurred item on
screen.
It also does nothing on Android 8.0 to 11: the setting reads Soft, Medium or Strong and the media
is drawn sharp.
And the media the blur hides is still fetched in full. An image scrolled past is read from disk,
decoded at its original resolution and kept in the image cache, only ever to be drawn as an
unrecognisable smear.
## Cause
`Modifier.blur` compiles to `graphicsLayer { renderEffect = BlurEffect(...) }`. In
`androidx.compose.ui:ui-android:1.8.2`, which is what this build resolves:
| layer implementation | API range | what `setRenderEffect` does |
| -------------------- | --------- | ------------------------------------- |
| `GraphicsLayerV23` | 23-28 | stores the field, never applies it |
| `GraphicsLayerV29` | 29+ | applies only when `SDK_INT >= 31` |
| `GraphicsViewLayer` | fallback | applies only when `SDK_INT >= 31` |
`minSdk` is 26, so on Android 8.0 to 11 the effect is silently dropped and the media is drawn
unblurred. The codebase already knows this - `SimpleXAPI.kt` has
`deviceSupportsBlur = ... androidApiLevel >= 32` - but that gates the app bar blur and the bar
alpha, never the media blur.
The radius is in *display* pixels: `BlurKt` converts it with `toPx` on the `GraphicsLayerScope`
before constructing the `BlurEffect`. The convolution therefore runs over the layer, not over the
source bitmap - its cost does not depend on the source resolution, and it is paid again on every
frame the layer is drawn.
The loading is independent of the blur. `CIImageView` calls `getLoadedImage`, which reads the file
bytes and decodes a 1000 px bitmap, and then hands the same bytes to Coil at `Size.ORIGINAL`.
`imageLoader` overrides neither `memoryCache` nor `memoryCachePolicy`, so Coil 2.6.0 installs its
default memory cache with strong references and retains those full-resolution bitmaps across items.
## Fix
A blur and a downscale discard the same thing: detail finer than their radius. So the preview is
resampled to about one pixel per radius, then doubled back up until its longest side is at least
200 px, and drawn stretched to the item with the bilinear filtering `drawImage` uses by default.
This runs once when the item composes rather than once per frame, needs no `RenderEffect`, and so
works on every supported Android version.
The doubling is what makes it read as a blur rather than as pixels. Stretching a 30 px image
straight to the screen interpolates between its pixels with a tent, and a tent 12 to 48 screen
pixels wide shows the grid it sits on as soft squares and crosses. Each bilinear doubling
convolves that tent with another one, so three or four doublings turn it into a bell close to the
Gaussian that `Modifier.blur` drew, and the last stretch to the screen is short enough that no
grid shows. Compose cannot do this at draw time: on Android `FilterQuality` only toggles
`Paint.isFilterBitmap`, so `High` is still bilinear there, and only desktop maps it to a cubic
resampler.
`BLURRED_MEDIA_WIDTH_DP` fixes the strength: it is the width the radius is measured against, so
Soft, Medium and Strong resample to 400/12, 400/24 and 400/48, that is 33, 16 and 8 px wide. It is
the one number to move if the result reads too soft or too sharp. It started at 360, the width the
media is drawn at, and was calibrated to 400 by fitting: for each setting the pipeline's output was
matched against Gaussians of every sigma to find the one it resembles most. At 360 the fitted sigma
was 105%, 120% and 175% of the old blur's for Soft, Medium and Strong, the last because 7 px is too
coarse to hold the picture; at 400 it is 105%, 110% and 95% on a 300 px preview and 90%, 95% and
105% on a 100 px one. 440 sits at 80-95% across the board, so 400 is the closest match and 440 the
next step lighter.
Resampling is also the better direction for a privacy control. A Gaussian is a convolution and is
in principle partly invertible; a 7 px bitmap does not contain the detail to recover.
While the blur hides the media, the file is left unread - no disk read, no 1000 px decode, no
full-resolution decode, no cache entry. `blurHidesMedia()` is the single definition that both the
modifier drawing the blur and the decision to load the file consult, so the two cannot disagree
about whether the media is on screen. Getting that wrong in either direction is a defect: one way
the app shows what it promised to hide, the other it hides an image it will never load.
## Bounds
The media dimensions come from the sender, so the descent is bounded rather than trusted. Its
first step caps both sides at 512 px and the resampled image at 400 px, so no image, whatever its
shape, produces a large intermediate. That first step samples rather than averages - reading every
pixel of a 4K video frame would stall composition - and the halving that follows averages away
most of what it aliases. The ascent stops as soon as the longest side reaches 200 px, so it never
makes an image with a side of 400 px or more; a 4:3 preview ends at 264x192 px, 203 KB.
The arithmetic was fuzzed over 4840 dimension and radius combinations: no crash, no
non-termination, every dimension at least 1, no side over 400 px after the first step, largest
single intermediate 1.00 MB (the 512 px first step), largest total churn 1.68 MB. A zero radius
cannot reach the function; a negative one - possible from imported settings - yields a 1x1 image
doubled to a flat 256x256, so it fails toward more blur rather than less.
## Verification
Built as a desktop AppImage from this branch and run against a profile with images and videos.
Images and video previews render as a smooth blur with no visible blockiness, and the play button
stays suppressed until the media is revealed. The shipped class was checked to contain the new path
and no remaining reference to `BlurKt` or `BlurredEdgeTreatment`.
The doubling was chosen with a Java2D harness that runs the same arithmetic through the same
bilinear `drawImage` the desktop `scale` uses, on a 300, 100 and 48 px preview at each radius,
next to the Gaussian `Modifier.blur` drew (sigma 0.577r + 0.5, as `BlurEffect` converts it). Without
the ascent the result shows the resample grid at every radius; with it the grid is gone at 1x and
3x density, and RMSE against the Gaussian falls (300 px, Soft: 5.8 to 5.0; 48 px, Soft: 5.3 to
3.5). Doubling past 200 px changes neither the picture nor the figure.
No before/after CPU figure is included. The only machine available had no GPU, so Skiko fell back
to software rasterisation; a comparison measured there would overstate the gain, because on real
hardware the old blur was largely GPU work. The claim this change rests on is structural - a
constant recomputed every frame is now computed once - not measured.
The Android 8.0 to 11 no-op is read from the bytecode of the resolved artifact and has not been
confirmed on a device of that vintage.
## Out of scope
iOS is unchanged. It has the same per-frame `.blur(radius:)` in `PrivacyBlur`, and `getLoadedImage`
there does no downsampling at all, so the same change applies - but as its own diff.
`ChatInfoImage.kt` still blurs blocked members' avatars with `Modifier.blur`, so that one remains a
no-op below API 31. It is a different feature and a different call site.
`CIVideoView` still extracts a full-resolution frame through the video player while the preview is
blurred, the way `CIImageView` used to. The same treatment applies; the player's lifecycle makes it
a larger change than this one.
@@ -0,0 +1,213 @@
# Open Member Profile Without Waiting for the Core
## Context
`#7388` removed `apiListMembers` from the member profile tap, which was O(group
size) and took seconds in a group with 10000 members. It did not remove the
dependency on the core.
`showMemberInfo` (ChatView.kt:499) still awaited two calls before the modal was
created:
```kotlin
val r = chatModel.controller.apiGroupMemberInfo(...) // awaited
val (updatedMember, code) = if (...) {
val memCode = chatModel.controller.apiGetGroupMemberCode(...) // awaited
...
}
...
ModalManager.end.showModalCloseable(...) { ... } // only now
```
Both are single-row queries, 1-2 ms when the core is idle, which is why the tap
looks instant in testing. But `sendCmd` is serialized against everything else the
core is doing, so while it is busy - startup, a batch of incoming events, a long
database operation - the tap produces nothing at all until the core drains. The
profile opens at the speed of the core rather than the speed of the UI.
The same wait is in `GroupChatInfoView.kt:125` and `MemberSupportChatView.kt:78`.
## Root Cause
The modal was created inside the coroutine, after both awaits, and the loaded
values were passed into the view as plain parameters:
```kotlin
fun GroupMemberInfoView(
...
connectionStats: ConnectionStats?,
connectionCode: String?,
...
)
```
A plain parameter can only be supplied by a caller that has already waited.
Each call site also resolved the member through the model and rendered nothing
when it was absent:
```kotlin
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> ... }
```
In a large group where members were never loaded, that is an empty card - which
is why `#7388` had to write the member into the model *before* showing the modal.
## Solution
Open the modal first and let the loaded values arrive afterwards. The two
parameters become state the caller creates at the tap and fills in when the
queries return:
```kotlin
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
ModalManager.end.showModalCloseable(...) { close ->
GroupMemberInfoView(chatRh, groupInfo, member, scrollToItemId, connStats, connectionCode, ...)
}
groupMembersJob = scope.launch(Dispatchers.Default) {
...unchanged...
connStats.value = r?.second
connectionCode.value = code
}
```
The card is shown from the member that is already known at the tap - from the
chat item, or from the members list - with the model's copy preferred once it is
there:
```kotlin
val member = remember(groupMember.groupMemberId) {
derivedStateOf { chatModel.getGroupMember(groupMember.groupMemberId) ?: groupMember }
}.value
```
The `?: groupMember` fallback is what removes the dependency: the card no longer
needs the model to contain the member, so nothing has to happen before the modal
opens.
## Technical Design
### The state is created at the tap, not in the view
`ModalManager.showInView` drives `AnimatedContent` from `modalCount` alone
(ModalView.kt:207), so the card is disposed while a sub-screen is open and
re-composed on return. State held by `remember` inside the view would lose the
loaded values there, and the rows would disappear. A `MutableState` created in
the click handler is captured by the modal lambda and outlives every
recomposition of the card, with no storage of its own.
### The loaded code cannot come from the model
`APIGetGroupMemberCode` returns the live code beside the member
(Commands.hs:2047); the member it returns carries only the verification record,
which the core *clears* when the code no longer matches. So the code has to be
held by the UI - it cannot be read back off `member.activeConn`.
### The rows are reserved, not gated and not inserted
iOS wraps everything below the action buttons in `if connectionLoaded`
(GroupMemberInfoView.swift:123-288) - the action section, Address, Member,
Servers, Connection failed and For console. Until both queries return its card is
a header and three buttons over blank space. That is tolerable when the core
answers in 1-2 ms; it is the wrong trade for the case this change exists for,
where the core is busy and the wait is what the user sees.
Letting each row appear as its data arrives is no better: the card is complete
from the first frame but grows under the finger.
So the rows that depend on the load are rendered from the first frame in their
final positions, disabled, and enabled in place when the data arrives:
```kotlin
val canVerifyCode = member.memberRole != GroupMemberRole.Relay && (connectionCode != null || !connectionLoaded)
...
VerifyCodeButton(member.verified, verifyClicked, disabled = connectionCode == null)
```
`SectionItemView` already drops the `clickable` modifier when `disabled`
(Section.kt:213), so a disabled row is inert and identical in size to the enabled
one - nothing moves when it is enabled.
Only rows that can be predicted locally are reserved:
- **Verify security code.** The enclosing condition is already
`memberActive || (useRelays && memberCurrent)`, so inside it the loader's
`getCode` reduces to `memberRole != Relay` - known from the member at the tap.
- **Servers.** Reserved as a whole section with a disabled "Change receiving
address" and empty Receiving/Sending rows, for a member the same condition says
is connected.
- **Fix connection is not reserved.** `canSyncConn` needs
`cStats.ratchetSyncAllowed`, which is false except during ratchet desync, so a
placeholder for it would disappear on nearly every open - the same jump in the
opposite direction.
A third state, `connectionLoaded`, is created at the tap beside the other two and
set when the queries return, including on failure. Without it a failed load would
leave the row disabled forever, because "no code yet" and "no code at all" look
the same.
Network status is reserved with them. It looks like agent state that cannot be
predicted, but `connSubStatus` (simplexmq Agent.hs:2736) returns `Just` whenever
`rcvQueuesInfo` is non-empty and `Nothing` only when it is empty - the same list
that decides whether "Receiving via" is rendered. The two rows appear together,
so reserving one and not the other is what leaves a gap.
The remaining shift is "Abort changing address", which renders only while
`rcvSwitchStatus != null` on a receiving queue. That is a switch the user
started, so it is absent in normal use and reserving it would add a row that
disappears on nearly every open.
iOS needs its gate for a reason Kotlin does not have: `newRole` is `@State`
initialised to a placeholder `.member` and corrected inside `.task`
(GroupMemberInfoView.swift:30, :297), so the role picker must not render before
that runs. Kotlin seeds it from the member inline -
`remember { mutableStateOf(member.memberRole) }` - and is correct on the first
frame.
### The chat-change guard is kept
`#7388` added `chatModel.chatId.value != groupInfo.id` so that a load finishing
after the chat changed does not write to the model. The modal now opens before
the guard is reached, and `KeyChangeEffect` (:56) closes the card on chat change
as before, so the guard is left exactly as it was.
`groupMembersJob.cancel()` is kept in the tap handler: `info` (ChatView.kt:390)
preloads members *before* showing its modal, so without the cancel a pending chat
info load would open its modal on top of the member card.
## Consequences
- The card renders from the member known at the tap, so it can be marginally
staler for the duration of the load. iOS has the same window - it renders the
header before `connectionLoaded`.
- "Verify security code" and the Servers section are shown disabled until the
queries return, so the card does not change height as they arrive. This is a
third behaviour, matching neither the old Kotlin (rows inserted as data landed)
nor iOS (whole body hidden until loaded); porting it to iOS would be the way to
converge them.
- "Fix connection" still appears on arrival when the ratchet needs syncing. It is
rare enough that reserving space for it would move the card more often than it
saves.
- "Send message" for a member with `!sendMsgEnabled` does nothing if tapped
before the stats arrive. iOS is identical: `createMemberContactButton` has the
same `else if let connStats` with no else, and renders the action buttons
before `connectionLoaded`.
- The modal is now opened from the click handler on the main thread instead of
from `Dispatchers.Default` / `withBGApi`. All three callers are UI click
handlers, so this is strictly safer - `modalViews` is a plain `ArrayList`.
## Out of Scope
- **Collapsing the three copies of the fetch.** The same two queries and the same
condition are still written out at all three call sites. That duplication has
already drifted once - `#7255` widened the condition for channel members in two
of the three and in iOS, and `ChatView.kt` was left behind until `#7486` - and
moving the load into the view, as iOS has done since `#5008`, would remove it.
It is a restructure rather than a fix, so it belongs in its own change.
- Message *Info* (ChatView.kt:706) still loads all members; it needs them to
resolve delivery recipients. A narrower core API would be required.
- Opening a second member's profile while one is already open does not switch the
card: `closeModals()` sets `modalCount` to 0 and `showCustomModal` back to 1, so
`AnimatedContent`'s target never changes. The close and open are back to back in
both the old and the new code, so this is neither introduced nor worsened.
@@ -0,0 +1,86 @@
# Fix Android app opening simplex.chat web pages as connection links
## Problem
Tapping `https://simplex.chat/crowdfunding/` in a chat and confirming "Open" in the
"Open web link?" alert shows "Invalid connection link" instead of opening the page
in the browser. `/call/`, `/credits/` and `/about/` are affected the same way. iOS and
desktop are not.
## Cause
"Open" calls `safeOpenUri` → `uriHandler.openUri` (`TextItemView.kt`), which on Android
fires an `ACTION_VIEW` intent. The app's own verified app-link filter in
`android/src/main/AndroidManifest.xml` matches the `simplex.chat` host with:
```xml
<data android:pathPrefix="/invitation" />
<data android:pathPrefix="/contact" />
<data android:pathPrefix="/a" />
<data android:pathPrefix="/c" />
<data android:pathPrefix="/g" />
<data android:pathPrefix="/i" />
```
`pathPrefix` is a plain string prefix, so `/c` also matches `/crowdfunding/`, `/call/`
and `/credits/`, and `/a` matches `/about/`. The single-letter prefixes were added
for short links in #5824. Android delivers the intent back to SimpleX, `processIntent`
sets `appOpenUrl`, and `connectIfOpenedViaUri` → `planAndConnect` tries to connect via
the web page URL and fails.
iOS is not affected: `website/src/.well-known/apple-app-site-association` lists exact
components (`/c` and `/c/*`, etc.), not string prefixes. Desktop registers no https
handler.
## Fix
Make the Android filter match the iOS one: replace each `pathPrefix="/X"` with an exact
`path="/X"` plus `pathPrefix="/X/"`.
```xml
<data android:path="/c" />
<data android:pathPrefix="/c/" />
```
Connection links carry their data in the fragment (`/contact#/?v=…`, `/a#key`), which
is not part of the matched path, so they still match the exact path. Links with a
trailing slash or a subpath (`/contact/#…`, `/c/…`) match the `/X/` prefix, the same as
`/X/*` on iOS.
App-link verification (`autoVerify`, `assetlinks.json`) is per host and is not changed
by the path list.
## Alternatives considered
- Detecting non-connection URLs in `processIntent` and re-opening them in the browser:
a second `ACTION_VIEW` resolves back to the same filter, so the app would need an
explicit browser component. That still sends every such link through SimpleX first,
including links tapped in other apps.
- `pathPattern`: it has no anchors or alternation that `path` + `pathPrefix` doesn't
already cover, and it is harder to read.
## Verification
Checked on an API 27 emulator using stub APKs that contain only the app's intent
filter, with `cmd package query-activities -a android.intent.action.VIEW -c
android.intent.category.BROWSABLE -d <url>`:
| URL | before | after |
|---|---|---|
| `https://simplex.chat/crowdfunding/` | SimpleX + browser | browser |
| `https://simplex.chat/call/` | SimpleX + browser | browser |
| `https://simplex.chat/credits/` | SimpleX + browser | browser |
| `https://simplex.chat/about/` | SimpleX + browser | browser |
| `https://simplex.chat/contacts` | SimpleX + browser | browser |
| `https://simplex.chat/blog/` | browser | browser |
| `https://simplex.chat/contact#/?v=1` | SimpleX + browser | SimpleX + browser |
| `https://simplex.chat/contact/#/?v=1` | SimpleX + browser | SimpleX + browser |
| `https://simplex.chat/invitation#/?v=1` | SimpleX + browser | SimpleX + browser |
| `https://smp4.simplex.im/a#abc` | SimpleX + browser | SimpleX + browser |
| `https://simplex.chat/c#abc` | SimpleX + browser | SimpleX + browser |
| `https://simplex.chat/c/abc` | SimpleX + browser | SimpleX + browser |
| `https://simplex.chat/g#x` | SimpleX + browser | SimpleX + browser |
| `https://simplex.chat/i#x` | SimpleX + browser | SimpleX + browser |
The built arm64 debug APK's manifest was dumped with `aapt2` and contains the new
`path` / `pathPrefix` entries.
@@ -0,0 +1,49 @@
# Desktop: app freezes when a video is stopped while its format is being negotiated
## Problem
On desktop, playing a video in the chat and then opening it fullscreen (sometimes after a few attempts) leaves the fullscreen view black. The whole app stops responding: the back button, other views and the window close button all do nothing. A process restart is the only way out.
It reproduces with a video that makes VLC renegotiate its output format repeatedly (the log is full of `main filter error: Failed to create video converter`). Test videos that negotiate once did not reproduce it.
## Evidence
Reproduced on a build of `master` (VLC 3.0.23 bundled) by looping: play inline, click to stop, click to open fullscreen, back. It froze on the second iteration. Two `jcmd Thread.print` dumps taken 2 s apart were identical:
| Thread | Where it is |
|---|---|
| `pool-3-thread-1` (`VideoPlayer.playerThread`) | `libvlc_media_player_stop` ← `VideoPlayer.stop()` (`VideoPlayer.desktop.kt:117`) |
| `Thread-10825` (vlc decoder thread in a JNA callback) | `libvlc_video_get_track` ← `SkiaBitmapBufferFormatCallback.getBufferFormat` (`SkiaBitmapVideoSurface.kt:83`, `player.video().track()`) |
| `AWT-EventQueue-0` | `libvlc_media_player_get_time` ← `VideoPlayer.currentPosition` ← progress coroutine on `Dispatchers.Main` (`VideoPlayer.desktop.kt:94`) |
The JVM needs `-XX:+StartAttachListener` for `jcmd` to attach, because the Haskell RTS takes over `SIGQUIT`.
## Cause
In VLC 3.0.23:
- `libvlc_media_player_stop` (`lib/media_player.c:1065`) takes `lock_input` and, while holding it, calls `release_input_thread`, which ends in `input_Stop` + `input_Close` (`:183-184`). These wait for the input thread, and its decoder threads, to exit.
- `libvlc_video_get_track` (`lib/video.c:628`) and `libvlc_media_player_get_time` (`lib/media_player.c:1358`) both go through `libvlc_get_input_thread`, which takes the same `lock_input` (`lib/media_player.c:198`).
`getBufferFormat` runs on the decoder thread during format negotiation. When `stop()` arrives at that moment, `stop` holds the lock and waits for the decoder thread, and the decoder thread waits for the lock in `video().track()`. The UI thread's 50 ms `get_time` poll then blocks on the same lock, which freezes the app.
The `track()` call was added in #7391, which fixed stretched AV1 playback by using the track size instead of the padded size that libvlc passes.
## Fix
Stop calling `video().track()` in `getBufferFormat`. Take the track from `media().info().videoTracks()` only, using the `singleOrNull()` fallback the code already used before VLC had selected a track.
Why the remaining calls cannot deadlock the same way:
- `release_input_thread` is called only from `stop` (`:1066`) and `set_media` (`:886`), which hold only `lock_input` while waiting, and from `destroy` (`:819`), which holds no lock.
- `media()` → `libvlc_media_player_get_media` takes `object_lock` (`:922`), which is never held while waiting for the input thread.
- `info()` → `libvlc_media_tracks_get` takes only the media item's lock (`lib/media.c:967`).
Behaviour change: none for a file with one video track, which is every video SimpleX sends. A file with several video tracks gets the size libvlc passed, as it did before #7391.
## Alternatives considered
- **No libvlc calls in the callback at all.** Read the track size on `playerThread` before playback and hand it to the surface. This is more code and adds a metadata read before every play, and the lock analysis above shows it buys nothing for this deadlock.
- **Crop the padded frame instead of asking for the track size.** This does not work: vlc scales the picture to whatever size the callback returns, which is why AV1 was stretched before #7391.
- **Size from vlcj elementary-stream events.** These can arrive after the format has been negotiated, so the first frames would use the padded size.
- **Moving the progress polling and click handlers' libvlc calls off the UI thread.** This would stop any future libvlc stall from freezing the whole app, but it is a separate change across several files and is left out of this bugfix.
+100
View File
@@ -0,0 +1,100 @@
# Plan: make the desktop bars blur lightweight
## Context
Settings > Appearance > Blur blurs the app bars: `blurredBackgroundModifier` puts a `BlurEffect` on the bar's layer
and draws a copy of the scrolled content inside it, so the blur is recomputed every frame. On desktop this made
scrolling visibly laggy at the default radius of 50.
## Measurements
Measured on the software renderer (`SKIKO_RENDER_API=SOFTWARE`), 1376x768 window, density 1, scrolling a chat of 70
messages. Frames were counted by capturing the window repeatedly and counting distinct images.
| | blur off | blur 50 |
|---|---|---|
| visible frames per second while scrolling | 14.5 | 5.2 - 8.7 |
A standalone harness that replays the same drawing through Skiko's `RenderNode` puts the cost of one bar at 29 ms
(radius 30), 42 ms (radius 50) and 71 ms (radius 100). Two bars are drawn in a chat, so at the default radius the
bars alone cost about 80 ms per frame.
## Cause
Skia's raster blur runs at full resolution: `Raster8888BlurAlgorithm` only rescales its input above sigma 135
(`SkBlurEngine.cpp`), and every radius the slider offers is below that. Each bar therefore blurs
`width x (bar + 6 sigma)` pixels per frame, which at radius 100 is the full window width by 404 rows.
The obvious remedy, blurring a downscaled copy the way Skia's own GPU path does, cannot be expressed by scaling the
layer: **a layer's image filter is evaluated in device space**, so any scale on the layer or the canvas is applied to
the sigma as well. Narrowing a bar eightfold and blurring it with an eighth of the sigma measured 68.5 ms against
68.4 ms for the untouched blur, and looked wrong, because the reduced sigma ended up applied at full resolution.
This is why the blur appeared weak enough to leave text under the bar readable.
## Fix
Draw the bar into an offscreen Skia surface that is up to 8x narrower, blur it there, where the surface's own
resolution is the device resolution and the sigma is not scaled back up, then stretch the finished blur across the
bar. Only the width is reduced: a bar is `AppBarHeight` tall, and taking rows away from it visibly weakens the blur,
which the harness confirmed and which was visible in the app.
`drawBarsBlurred` is an `expect`/`actual` because it needs Skia surfaces directly. Android is untouched: it blurs
through a `RenderEffect` on the layer, where the GPU blur rescales internally, so it never had this cost.
Blurring a copy has one consequence for when the bars draw. Previously the bar recorded `drawLayer(graphicsLayer)`, a
live reference that replays whatever the content layer holds at playback, so the bar did not have to be redrawn when
the content scrolled. Reading the content into a surface fixes it at the moment the bar draws instead, so the bar has
to be redrawn whenever that copy is re-recorded, which `AppBarHandler.contentVersion` provides: `copyViewToAppBar`
bumps it after each recording and the bar reads it. Without it the blur inside a chat lagged behind the content,
while the chat list looked correct because its bar happened to be invalidated by other state.
The surfaces are kept per size rather than one pair overall, because the desktop window's panes have different
widths and their bars are drawn in the same frame, which would otherwise reallocate both surfaces for every bar.
How far the copy is reduced follows from the blur itself. The width is divided until the blur's own sigma would fall
below 1.5, past which the narrowed copy is barely blurred and stretching it back out shows the steps of the narrowing
rather than a blur. The rows are halved once, with the vertical sigma halved to match. The rule has to be written in
terms of the reduced sigma rather than the radius: stopping as soon as the reduced sigma reached 4, as a first version
did, left the small radiuses barely narrowed at all and made radius 10 cost more than radius 50.
The blur is the surface's own layer paint rather than a copy between two surfaces, which is both one allocation fewer
and, measured with the surfaces reused as the app reuses them, 3-11% quicker.
Per bar, against the unmodified blur, with surfaces reused:
| radius | before | after | difference from the original |
|---|---|---|---|
| 10 | 17.9 ms | 2.7 ms | 0.41 / 255 |
| 50 | 40.9 ms | 3.0 ms | 0.23 / 255 |
| 100 | 72.7 ms | 5.0 ms | 0.27 / 255 |
The difference column is the root mean square difference over the bar, before the bar's own tint is applied, so what
reaches the screen is smaller still. Redrawing the bar and stretching the blur back over it cost 0.5 ms of those
figures whatever the radius, so that is the floor this approach can reach.
## Keeping the bars in step with the content
A layer's own filter follows the content it is attached to for free. Blurring a copy does not: the copy is taken when
the bar draws, so the bar has to be redrawn whenever the content it copies has moved. `AppBarHandler.contentVersion`
carries that, and the scroll containers bump it from a collector on the state they actually scroll.
Two details are load-bearing. The bump must not happen while drawing: an earlier version bumped it inside
`copyViewToAppBar`, and because Compose Desktop redraws the whole scene per frame, the content's draw invalidated the
bar, which requested another frame, which drew the content again. Measured idle, with no interaction: **96% of a core**
against 0% with the blur off and 0% for the unmodified app. And the version has to come from the state the container
actually scrolls, not from the handler's own: a chat supplies its own `LazyListState`, so watching the handler's left
the chat's blur stale while the chat list looked correct.
## Rejected
Reusing the blurred copy while the content is unchanged was measured and dropped: it saves nothing while scrolling,
which is the case that lags, and the chat wallpaper is recorded into its own layer without touching the content
version, so a cached blur could outlast a wallpaper change.
Blurring the bars of both panes in one pass does not apply: only `DefaultAppBar` is blurred, one per screen, and the
two on a desktop window belong to panes of different widths side by side rather than stacked.
## Verification
Built as an AppImage and checked by eye across the whole slider range at bar alpha 0.5, where the blur is most
exposed, on top of the chat list and inside a chat.
+47
View File
@@ -0,0 +1,47 @@
# Desktop: crash at startup on Windows when WMI does not answer
Fixes #7580.
## Problem
The desktop app exits at startup on some Windows 10 machines:
```
java.lang.ExceptionInInitializerError
at com.jthemedetecor.OsThemeDetector.getDetector(OsThemeDetector.java:43)
at chat.simplex.common.platform.Resources_desktopKt.<clinit>(Resources.desktop.kt:29)
...
Caused by: java.lang.NullPointerException: Cannot invoke "String.compareTo(String)" because "buildNumber" is null
at oshi.software.os.OperatingSystem$OSVersionInfo.<init>(OperatingSystem.java:510)
at oshi.software.os.windows.WindowsOperatingSystem.queryFamilyVersionInfo(WindowsOperatingSystem.java:172)
...
at com.jthemedetecor.util.OsInfo.<clinit>(OsInfo.java:27)
```
## Cause
`jSystemThemeDetector:3.8` depends on `oshi-core:5.8.6`. Its `OsInfo` static initialiser calls `OperatingSystem.getVersionInfo()`.
In oshi 5.8.6, `WindowsOperatingSystem.queryFamilyVersionInfo()` starts with `buildNumber = null` and sets it only if the WMI query for `Win32_OperatingSystem` returns a row. `WmiQueryHandler.queryWMI` returns an empty result on any `COMException` (invalid namespace/class, access denied, broken WMI repository or service) or on a timeout. The `OSVersionInfo` constructor then runs:
```java
if ("10".equals(version) && buildNumber.compareTo("22000") >= 0) {
```
It throws when `os.name` is "Windows 10" and WMI returned nothing. So only machines with a failing WMI query crash; a healthy WMI returns the build number.
The app calls `OsThemeDetector.getDetector()` in top-level `val` initialisers (`Resources.desktop.kt`, `Theme.desktop.kt`). The failure is an `Error` raised while the class loads, so nothing catches it. `settings` lives in the same file, so the first preference read in `runMigrations` fails and the app exits.
## Fix
Add `com.github.oshi:oshi-core:6.4.13` to `desktopMain`. Gradle conflict resolution replaces the transitive 5.8.6 with it.
- The `buildNumber.compareTo` check is in every oshi release up to 6.3.0 and was removed in 6.3.1. There the constructor just stores the null build number.
- 6.4.13 is built against JNA 5.14.0, the version the app already pins (`jna`, `jna-platform`). 6.3.1 used 5.12.1 and 6.6.x uses 5.16.0.
- jSystemThemeDetector 3.8 uses only `SystemInfo.<init>`, `getOperatingSystem`, `getCurrentPlatform`, `OperatingSystem.getFamily`, `getVersionInfo`, `OSVersionInfo.getVersion` and `PlatformEnum.{WINDOWS,MACOS,LINUX}`. All of them exist unchanged in 6.4.13.
## Verification
- `new OSVersionInfo("10", "", null)` throws the reported NPE with oshi 5.8.6 and returns `10` with 6.4.13.
- With 6.4.13, jSystemThemeDetector 3.8 and JNA 5.14.0 on the classpath, `OsThemeDetector.getDetector()` loads and returns a detector.
- `:common:dependencyInsight --configuration desktopRuntimeClasspath --dependency oshi-core` shows `5.8.6 -> 6.4.13`.
+1 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 7.1.0.7
version: 7.1.0.8
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -144,7 +144,7 @@ SEARCH r USING INDEX idx_rcv_file_chunk_replicas_rcv_file_chunk_id (rcv_file_chu
SEARCH s USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT c.corr_id, cs.user_id, c.command
SELECT c.corr_id, cs.user_id, c.command, c.created_at
FROM commands c
JOIN connections cs USING (conn_id)
WHERE c.command_id = ?
@@ -1136,7 +1136,7 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?)
SEARCH c USING PRIMARY KEY (conn_id=?)
Query:
SELECT c.user_id, q.conn_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash), q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id,
SELECT c.user_id, q.conn_id, q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id,
q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id
FROM rcv_queues q
JOIN servers s ON q.host = s.host AND q.port = s.port
@@ -1148,7 +1148,7 @@ SEARCH q USING PRIMARY KEY (host=? AND port=?)
SEARCH c USING PRIMARY KEY (conn_id=?)
Query:
SELECT c.user_id, q.conn_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash), q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id,
SELECT c.user_id, q.conn_id, q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id,
q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id
FROM rcv_queues q
JOIN servers s ON q.host = s.host AND q.port = s.port
@@ -1160,7 +1160,7 @@ SEARCH q USING PRIMARY KEY (host=? AND port=?)
SEARCH c USING PRIMARY KEY (conn_id=?)
Query:
SELECT c.user_id, q.conn_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash), q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id,
SELECT c.user_id, q.conn_id, q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id,
q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id
FROM rcv_queues q
JOIN servers s ON q.host = s.host AND q.port = s.port