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/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/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/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg
index fcf54b281e..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 1dec1bd4ee..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 b14749dc91..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 f1598e3732..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 70badd7f13..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 105f09d455..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 0539fbe593..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 ba7027bded..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-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-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 3eb1c04d3b..fac3873a71 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 aec4d76287..833a1ae602 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 = ?
@@ -249,6 +249,26 @@ Plan:
SEARCH snd_files USING INDEX idx_snd_files_status_created_at (status=? AND created_at>?)
USE TEMP B-TREE FOR ORDER BY
+Query:
+ SELECT DISTINCT
+ s.xftp_host, s.xftp_port, s.xftp_key_hash
+ FROM rcv_file_chunk_replicas r
+ JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id
+ JOIN rcv_file_chunks c ON c.rcv_file_chunk_id = r.rcv_file_chunk_id
+ JOIN rcv_files f ON f.rcv_file_id = c.rcv_file_id
+ WHERE (f.rcv_file_id = ? OR f.redirect_id = ?) AND r.replica_number = 1
+
+Plan:
+MULTI-INDEX OR
+INDEX 1
+SEARCH f USING INTEGER PRIMARY KEY (rowid=?)
+INDEX 2
+SEARCH f USING COVERING INDEX idx_rcv_files_redirect_id (redirect_id=?)
+SEARCH c USING COVERING INDEX idx_rcv_file_chunks_rcv_file_id (rcv_file_id=?)
+SEARCH r USING INDEX idx_rcv_file_chunk_replicas_rcv_file_chunk_id (rcv_file_chunk_id=?)
+SEARCH s USING INTEGER PRIMARY KEY (rowid=?)
+USE TEMP B-TREE FOR DISTINCT
+
Query:
SELECT MAX(internal_id)
FROM messages
@@ -1001,7 +1021,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
@@ -1013,7 +1033,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
@@ -1025,7 +1045,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
@@ -1374,6 +1394,10 @@ Query: UPDATE rcv_files SET key = ?, nonce = ?, chunk_size = ?, updated_at = ? W
Plan:
SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?)
+Query: UPDATE rcv_files SET status = ?, updated_at = ? WHERE (rcv_file_id = ? OR redirect_id = ?) AND status = ?
+Plan:
+SEARCH rcv_files USING INDEX idx_rcv_files_status_created_at (status=?)
+
Query: UPDATE rcv_files SET status = ?, updated_at = ? WHERE rcv_file_id = ?
Plan:
SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?)
@@ -1430,6 +1454,10 @@ Query: UPDATE snd_files SET status = ?, updated_at = ? WHERE snd_file_id = ?
Plan:
SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?)
+Query: UPDATE snd_files SET status = ?, updated_at = ? WHERE snd_file_id = ? AND status = ?
+Plan:
+SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?)
+
Query: UPDATE snd_messages SET rcpt_internal_id = ?, rcpt_status = ? WHERE conn_id = ? AND internal_snd_id = ?
Plan:
SEARCH snd_messages USING PRIMARY KEY (conn_id=? AND internal_snd_id=?)