diff --git a/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift
index c9924223b5..0ee07ea828 100644
--- a/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift
+++ b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift
@@ -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)
diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
index 6cafb78043..79023a5821 100644
--- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
+++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
@@ -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)
diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
index c1d1e5b111..d10c199e78 100644
--- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
@@ -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()
diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml
index 9e059afa14..64d7a03cf7 100644
--- a/apps/multiplatform/android/src/main/AndroidManifest.xml
+++ b/apps/multiplatform/android/src/main/AndroidManifest.xml
@@ -100,12 +100,18 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts
index 413667c968..a03a7d4b27 100644
--- a/apps/multiplatform/common/build.gradle.kts
+++ b/apps/multiplatform/common/build.gradle.kts
@@ -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")
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/BarsBlur.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/BarsBlur.android.kt
new file mode 100644
index 0000000000..bae80e11d0
--- /dev/null
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/BarsBlur.android.kt
@@ -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()
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/BarsBlur.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/BarsBlur.kt
new file mode 100644
index 0000000000..a7bd229d7f
--- /dev/null
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/BarsBlur.kt
@@ -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)
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt
index be7022ca80..5de9b7a4f4 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt
@@ -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): Boolean =
+ enabled && blurred.value && remember { appPrefs.privacyMediaBlurRadius.state }.value > 0
+
@Composable
fun Modifier.privacyBlur(
enabled: Boolean,
+ preview: ImageBitmap,
blurred: MutableState = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) },
scrollState: State,
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
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt
index 91b3270dea..b35e828962 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt
@@ -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,
)
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
index 7e43db2e98..091924e3c7 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
@@ -499,6 +499,15 @@ fun ChatView(
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
hideKeyboard(view)
groupMembersJob.cancel()
+ val connStats = mutableStateOf(null)
+ val connectionCode = mutableStateOf(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 ->
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
index 49e7ac6db7..d14da9dc33 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
@@ -123,6 +123,15 @@ fun ModalData.GroupChatInfoView(
}
},
showMemberInfo = { member, groupRelay ->
+ val connStats = mutableStateOf(null)
+ val connectionCode = mutableStateOf(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 = {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt
index 3e5f64659b..885d3de4e6 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt
@@ -45,10 +45,11 @@ import kotlinx.coroutines.*
fun GroupMemberInfoView(
rhId: Long?,
groupInfo: GroupInfo,
- member: GroupMember,
+ groupMember: GroupMember,
scrollToItemId: MutableState,
- connectionStats: ConnectionStats?,
- connectionCode: String?,
+ connStats: MutableState,
+ connectionCode: State,
+ connectionLoaded: State,
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,
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 = {},
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt
index 1ac6d6f048..0da3c46abb 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt
@@ -76,6 +76,15 @@ fun MemberSupportChatAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
title = { MemberSupportChatToolbarTitle(scopeMember_) },
onTitleClick = {
+ val connStats = mutableStateOf(null)
+ val connectionCode = mutableStateOf(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,
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt
index 61b6e450c5..5f5c207d08 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt
@@ -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?> = 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) })
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt
index 556b386900..ef600c8443 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt
@@ -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,
)
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt
index fae1b7c155..5f296655b7 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt
@@ -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 })
}
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt
index c7553b6ed0..6b39c80058 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt
@@ -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,
keyboardCoversBar: Boolean,
onTop: Boolean,
+ handler: AppBarHandler,
graphicsLayer: GraphicsLayer,
backgroundGraphicsLayer: GraphicsLayer,
backgroundGraphicsLayerSize: State,
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)
}
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt
index ad6611b9d9..3e29649517 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt
@@ -78,6 +78,10 @@ class AppBarHandler(
val backgroundGraphicsLayerSize: MutableState = 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 = mutableStateOf(0)
+
companion object {
var appBarMaxHeightPx: Int = 0
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt
index d2a98ae101..0c6fec52f0 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt
@@ -194,7 +194,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel
@Composable
fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State, 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, 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)) {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt
index 0fa53ea6de..39a6514302 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt
@@ -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) {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt
index c49caf7cfa..32f1b6384a 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt
@@ -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)
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
index 42aecc9cfe..bce235edec 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
@@ -1015,6 +1015,7 @@
Markdown in messages
Send questions and ideas
Send us email
+ Follow SimpleX Network News
SimpleX Lock
Chat console
Message servers
@@ -2678,8 +2679,8 @@
Crowdfunding on Wefunder.
Crowdfunding on Wefunder
Learn more on Wefunder
- Get a stake in SimpleX Chat!
- Invest on Wefunder from $100
+ Invest in SimpleX Chat!
+ Crowdfunding on Wefunder
SimpleX public names (BETA)
Public names for your channel or business.
Better channels 📢
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt
index 26748b8425..3fb9bd600c 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt
@@ -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
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/BarsBlur.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/BarsBlur.desktop.kt
new file mode 100644
index 0000000000..6c4ee704bb
--- /dev/null
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/BarsBlur.desktop.kt
@@ -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() }
+
+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
+ )
+ }
+}
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt
index 696e0efde8..2ebec123ad 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt
@@ -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)
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg
index 715a6daf99..93f959845f 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg
index f70ecb91db..d0de67a017 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg
index 48bfe6caa7..782d2b3823 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg
index 2bab828717..b489708eca 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg
index 704cae8710..56c0b20a37 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg
index d7c1f4e088..1406f27805 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg
index e39e0836cb..17e4bc94ca 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg
index ce9b4a86ef..99d7484555 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg differ
diff --git a/plans/2026-09-10-blur-media-resample.md b/plans/2026-09-10-blur-media-resample.md
new file mode 100644
index 0000000000..0eb3cfe0ab
--- /dev/null
+++ b/plans/2026-09-10-blur-media-resample.md
@@ -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.
diff --git a/plans/2026-09-10-member-profile-open-without-core.md b/plans/2026-09-10-member-profile-open-without-core.md
new file mode 100644
index 0000000000..01e9cebd3f
--- /dev/null
+++ b/plans/2026-09-10-member-profile-open-without-core.md
@@ -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(null)
+val connectionCode = mutableStateOf(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.
diff --git a/plans/2026-09-23-android-app-links-exact-paths.md b/plans/2026-09-23-android-app-links-exact-paths.md
new file mode 100644
index 0000000000..605f540924
--- /dev/null
+++ b/plans/2026-09-23-android-app-links-exact-paths.md
@@ -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
+
+
+
+
+
+
+```
+
+`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
+
+
+```
+
+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 | 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.
diff --git a/plans/2026-09-24-desktop-video-stop-deadlock.md b/plans/2026-09-24-desktop-video-stop-deadlock.md
new file mode 100644
index 0000000000..e9293efba6
--- /dev/null
+++ b/plans/2026-09-24-desktop-video-stop-deadlock.md
@@ -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.
diff --git a/plans/desktop-bars-blur-performance.md b/plans/desktop-bars-blur-performance.md
new file mode 100644
index 0000000000..4e113c90cb
--- /dev/null
+++ b/plans/desktop-bars-blur-performance.md
@@ -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.
diff --git a/plans/desktop-oshi-windows-npe.md b/plans/desktop-oshi-windows-npe.md
new file mode 100644
index 0000000000..9c8f9b3d54
--- /dev/null
+++ b/plans/desktop-oshi-windows-npe.md
@@ -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.(Resources.desktop.kt:29)
+ ...
+Caused by: java.lang.NullPointerException: Cannot invoke "String.compareTo(String)" because "buildNumber" is null
+ at oshi.software.os.OperatingSystem$OSVersionInfo.(OperatingSystem.java:510)
+ at oshi.software.os.windows.WindowsOperatingSystem.queryFamilyVersionInfo(WindowsOperatingSystem.java:172)
+ ...
+ at com.jthemedetecor.util.OsInfo.(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.`, `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`.
diff --git a/simplex-chat.cabal b/simplex-chat.cabal
index af810f8611..7ed610756f 100644
--- a/simplex-chat.cabal
+++ b/simplex-chat.cabal
@@ -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
diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt
index 99116fcdbf..48d2b5df79 100644
--- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt
+++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt
@@ -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