Merge branch 'master' into ep/profile-badges

This commit is contained in:
Evgeny @ SimpleX Chat
2026-09-26 19:56:44 +00:00
22 changed files with 517 additions and 53 deletions
@@ -100,12 +100,18 @@
<data android:host="smp4.simplexonflux.com" />
<data android:host="smp5.simplexonflux.com" />
<data android:host="smp6.simplexonflux.com" />
<data android:pathPrefix="/invitation" />
<data android:pathPrefix="/contact" />
<data android:pathPrefix="/a" />
<data android:pathPrefix="/c" />
<data android:pathPrefix="/g" />
<data android:pathPrefix="/i" />
<data android:path="/invitation" />
<data android:path="/contact" />
<data android:path="/a" />
<data android:path="/c" />
<data android:path="/g" />
<data android:path="/i" />
<data android:pathPrefix="/invitation/" />
<data android:pathPrefix="/contact/" />
<data android:pathPrefix="/a/" />
<data android:pathPrefix="/c/" />
<data android:pathPrefix="/g/" />
<data android:pathPrefix="/i/" />
</intent-filter>
<!-- Receive files from other apps -->
<intent-filter>
@@ -143,6 +143,7 @@ kotlin {
}
// For jSystemThemeDetector only
implementation("net.java.dev.jna:jna-platform:5.14.0")
implementation("com.github.oshi:oshi-core:6.4.13")
implementation("com.sshtools:two-slices:0.9.1")
implementation("org.slf4j:slf4j-simple:2.0.12")
implementation("uk.co.caprica:vlcj:4.8.3")
@@ -1205,12 +1205,13 @@ fun SynchronizeConnectionButtonForce(syncConnectionForce: () -> Unit) {
}
@Composable
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit, disabled: Boolean = false) {
SettingsActionItem(
if (contactVerified) painterResource(MR.images.ic_verified_user) else painterResource(MR.images.ic_shield),
stringResource(if (contactVerified) MR.strings.view_security_code else MR.strings.verify_security_code),
click = onClick,
iconColor = MaterialTheme.colors.secondary,
disabled = disabled,
)
}
@@ -499,6 +499,15 @@ fun ChatView(
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
hideKeyboard(view)
groupMembersJob.cancel()
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
val connectionLoaded = mutableStateOf(false)
if (chatsCtx.secondaryContextFilter == null) {
ModalManager.end.closeModals()
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close ->
GroupMemberInfoView(chatRh, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, close = close, closeAll = close)
}
groupMembersJob = scope.launch(Dispatchers.Default) {
val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId)
val stats = r?.second
@@ -513,15 +522,9 @@ fun ChatView(
withContext(Dispatchers.Main) {
chatModel.chatsContext.upsertGroupMember(chatRh, groupInfo, updatedMember)
}
if (chatsCtx.secondaryContextFilter == null) {
ModalManager.end.closeModals()
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close = close, closeAll = close)
}
}
connStats.value = stats
connectionCode.value = code
connectionLoaded.value = true
}
},
loadMessages = { chatId, pagination, visibleItemIndexes ->
@@ -123,6 +123,15 @@ fun ModalData.GroupChatInfoView(
}
},
showMemberInfo = { member, groupRelay ->
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
val connectionLoaded = mutableStateOf(false)
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
GroupMemberInfoView(rhId, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) {
closeCurrent()
close()
}
}
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId)
val stats = r?.second
@@ -132,14 +141,9 @@ fun ModalData.GroupChatInfoView(
} else {
member to null
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) {
closeCurrent()
close()
}
}
}
connStats.value = stats
connectionCode.value = code
connectionLoaded.value = true
}
},
editGroupProfile = {
@@ -45,10 +45,11 @@ import kotlinx.coroutines.*
fun GroupMemberInfoView(
rhId: Long?,
groupInfo: GroupInfo,
member: GroupMember,
groupMember: GroupMember,
scrollToItemId: MutableState<Long?>,
connectionStats: ConnectionStats?,
connectionCode: String?,
connStats: MutableState<ConnectionStats?>,
connectionCode: State<String?>,
connectionLoaded: State<Boolean>,
chatModel: ChatModel,
openedFromSupportChat: Boolean,
groupRelay: GroupRelay? = null,
@@ -60,7 +61,10 @@ fun GroupMemberInfoView(
}
BackHandler(onBack = close)
val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId }
val connStats = remember { mutableStateOf(connectionStats) }
// the passed member is shown until the loaded one is added to the model, so that the profile opens without waiting for the core
val member = remember(groupMember.groupMemberId) {
derivedStateOf { chatModel.getGroupMember(groupMember.groupMemberId) ?: groupMember }
}.value
val developerTools = chatModel.controller.appPrefs.developerTools.get()
var progressIndicator by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -91,7 +95,8 @@ fun GroupMemberInfoView(
connStats,
newRole,
developerTools,
connectionCode,
connectionCode.value,
connectionLoaded.value,
groupRelay = groupRelay,
getContactChat = { chatModel.getContactChat(it) },
openDirectChat = { contactId ->
@@ -101,6 +106,7 @@ fun GroupMemberInfoView(
}
},
createMemberContact = {
val connectionStats = connStats.value
if (member.sendMsgEnabled) {
withBGApi {
progressIndicator = true
@@ -209,7 +215,7 @@ fun GroupMemberInfoView(
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
VerifyCodeView(
mem.displayName,
connectionCode,
connectionCode.value,
mem.verified,
verify = { code ->
chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r ->
@@ -368,6 +374,7 @@ fun GroupMemberInfoLayout(
newRole: MutableState<GroupMemberRole>,
developerTools: Boolean,
connectionCode: String?,
connectionLoaded: Boolean,
groupRelay: GroupRelay? = null,
getContactChat: (Long) -> Chat?,
openDirectChat: (Long) -> Unit,
@@ -538,22 +545,23 @@ fun GroupMemberInfoLayout(
SectionDividerSpaced()
}
val memberConnected = member.memberActive || (groupInfo.useRelays && member.memberCurrent)
val showMemberSupportChat = !openedFromSupportChat &&
groupInfo.membership.memberRole >= GroupMemberRole.Moderator &&
member.memberRole != GroupMemberRole.Relay &&
((groupInfo.fullGroupPreferences.support.on && member.memberRole < GroupMemberRole.Moderator)
|| member.supportChat != null)
val canVerifyCode = connectionCode != null && member.memberRole != GroupMemberRole.Relay
// the same condition decides whether the code is requested, so the row is shown disabled until it arrives
val canVerifyCode = member.memberRole != GroupMemberRole.Relay && (connectionCode != null || !connectionLoaded)
val canSyncConn = cStats != null && cStats.ratchetSyncAllowed
if ((member.memberActive || (groupInfo.useRelays && member.memberCurrent))
&& (showMemberSupportChat || canVerifyCode || canSyncConn)) {
if (memberConnected && (showMemberSupportChat || canVerifyCode || canSyncConn)) {
SectionView {
if (showMemberSupportChat) {
SupportChatButton()
}
if (canVerifyCode) {
VerifyCodeButton(member.verified, verifyClicked)
VerifyCodeButton(member.verified, verifyClicked, disabled = connectionCode == null)
}
if (canSyncConn) {
SynchronizeConnectionButton(syncMemberConnection)
@@ -655,6 +663,20 @@ fun GroupMemberInfoLayout(
SimplexServers(stringResource(MR.strings.sending_via), sndServers)
}
}
} else if (!connectionLoaded && memberConnected) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) {
// the status is set from the same receiving queues as the servers below, so it is reserved with them
SectionItemView {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(MR.strings.network_status))
Icon(painterResource(MR.images.ic_info), stringResource(MR.strings.network_status), tint = MaterialTheme.colors.secondary)
}
}
SwitchAddressButton(disabled = true, switchAddress = {})
SimplexServers(stringResource(MR.strings.receiving_via), emptyList())
SimplexServers(stringResource(MR.strings.sending_via), emptyList())
}
}
val connFailedErr = member.activeConn?.connFailedErr
@@ -1117,6 +1139,7 @@ fun PreviewGroupMemberInfoLayout() {
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
developerTools = false,
connectionCode = "123",
connectionLoaded = true,
getContactChat = { Chat.sampleData },
openDirectChat = {},
createMemberContact = {},
@@ -76,6 +76,15 @@ fun MemberSupportChatAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
title = { MemberSupportChatToolbarTitle(scopeMember_) },
onTitleClick = {
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
val connectionLoaded = mutableStateOf(false)
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
GroupMemberInfoView(rhId, groupInfo, scopeMember_, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = true, close = closeCurrent) {
closeCurrent()
close()
}
}
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, scopeMember_.groupMemberId)
val stats = r?.second
@@ -85,14 +94,9 @@ fun MemberSupportChatAppBar(
} else {
null
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
remember { derivedStateOf { chatModel.getGroupMember(scopeMember_.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = true, close = closeCurrent) {
closeCurrent()
close()
}
}
}
connStats.value = stats
connectionCode.value = code
connectionLoaded.value = true
}
},
onTop = !oneHandUI.value || !chatBottomBar.value,
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 637 KiB

After

Width:  |  Height:  |  Size: 171 KiB

@@ -0,0 +1,213 @@
# Open Member Profile Without Waiting for the Core
## Context
`#7388` removed `apiListMembers` from the member profile tap, which was O(group
size) and took seconds in a group with 10000 members. It did not remove the
dependency on the core.
`showMemberInfo` (ChatView.kt:499) still awaited two calls before the modal was
created:
```kotlin
val r = chatModel.controller.apiGroupMemberInfo(...) // awaited
val (updatedMember, code) = if (...) {
val memCode = chatModel.controller.apiGetGroupMemberCode(...) // awaited
...
}
...
ModalManager.end.showModalCloseable(...) { ... } // only now
```
Both are single-row queries, 1-2 ms when the core is idle, which is why the tap
looks instant in testing. But `sendCmd` is serialized against everything else the
core is doing, so while it is busy - startup, a batch of incoming events, a long
database operation - the tap produces nothing at all until the core drains. The
profile opens at the speed of the core rather than the speed of the UI.
The same wait is in `GroupChatInfoView.kt:125` and `MemberSupportChatView.kt:78`.
## Root Cause
The modal was created inside the coroutine, after both awaits, and the loaded
values were passed into the view as plain parameters:
```kotlin
fun GroupMemberInfoView(
...
connectionStats: ConnectionStats?,
connectionCode: String?,
...
)
```
A plain parameter can only be supplied by a caller that has already waited.
Each call site also resolved the member through the model and rendered nothing
when it was absent:
```kotlin
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> ... }
```
In a large group where members were never loaded, that is an empty card - which
is why `#7388` had to write the member into the model *before* showing the modal.
## Solution
Open the modal first and let the loaded values arrive afterwards. The two
parameters become state the caller creates at the tap and fills in when the
queries return:
```kotlin
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
ModalManager.end.showModalCloseable(...) { close ->
GroupMemberInfoView(chatRh, groupInfo, member, scrollToItemId, connStats, connectionCode, ...)
}
groupMembersJob = scope.launch(Dispatchers.Default) {
...unchanged...
connStats.value = r?.second
connectionCode.value = code
}
```
The card is shown from the member that is already known at the tap - from the
chat item, or from the members list - with the model's copy preferred once it is
there:
```kotlin
val member = remember(groupMember.groupMemberId) {
derivedStateOf { chatModel.getGroupMember(groupMember.groupMemberId) ?: groupMember }
}.value
```
The `?: groupMember` fallback is what removes the dependency: the card no longer
needs the model to contain the member, so nothing has to happen before the modal
opens.
## Technical Design
### The state is created at the tap, not in the view
`ModalManager.showInView` drives `AnimatedContent` from `modalCount` alone
(ModalView.kt:207), so the card is disposed while a sub-screen is open and
re-composed on return. State held by `remember` inside the view would lose the
loaded values there, and the rows would disappear. A `MutableState` created in
the click handler is captured by the modal lambda and outlives every
recomposition of the card, with no storage of its own.
### The loaded code cannot come from the model
`APIGetGroupMemberCode` returns the live code beside the member
(Commands.hs:2047); the member it returns carries only the verification record,
which the core *clears* when the code no longer matches. So the code has to be
held by the UI - it cannot be read back off `member.activeConn`.
### The rows are reserved, not gated and not inserted
iOS wraps everything below the action buttons in `if connectionLoaded`
(GroupMemberInfoView.swift:123-288) - the action section, Address, Member,
Servers, Connection failed and For console. Until both queries return its card is
a header and three buttons over blank space. That is tolerable when the core
answers in 1-2 ms; it is the wrong trade for the case this change exists for,
where the core is busy and the wait is what the user sees.
Letting each row appear as its data arrives is no better: the card is complete
from the first frame but grows under the finger.
So the rows that depend on the load are rendered from the first frame in their
final positions, disabled, and enabled in place when the data arrives:
```kotlin
val canVerifyCode = member.memberRole != GroupMemberRole.Relay && (connectionCode != null || !connectionLoaded)
...
VerifyCodeButton(member.verified, verifyClicked, disabled = connectionCode == null)
```
`SectionItemView` already drops the `clickable` modifier when `disabled`
(Section.kt:213), so a disabled row is inert and identical in size to the enabled
one - nothing moves when it is enabled.
Only rows that can be predicted locally are reserved:
- **Verify security code.** The enclosing condition is already
`memberActive || (useRelays && memberCurrent)`, so inside it the loader's
`getCode` reduces to `memberRole != Relay` - known from the member at the tap.
- **Servers.** Reserved as a whole section with a disabled "Change receiving
address" and empty Receiving/Sending rows, for a member the same condition says
is connected.
- **Fix connection is not reserved.** `canSyncConn` needs
`cStats.ratchetSyncAllowed`, which is false except during ratchet desync, so a
placeholder for it would disappear on nearly every open - the same jump in the
opposite direction.
A third state, `connectionLoaded`, is created at the tap beside the other two and
set when the queries return, including on failure. Without it a failed load would
leave the row disabled forever, because "no code yet" and "no code at all" look
the same.
Network status is reserved with them. It looks like agent state that cannot be
predicted, but `connSubStatus` (simplexmq Agent.hs:2736) returns `Just` whenever
`rcvQueuesInfo` is non-empty and `Nothing` only when it is empty - the same list
that decides whether "Receiving via" is rendered. The two rows appear together,
so reserving one and not the other is what leaves a gap.
The remaining shift is "Abort changing address", which renders only while
`rcvSwitchStatus != null` on a receiving queue. That is a switch the user
started, so it is absent in normal use and reserving it would add a row that
disappears on nearly every open.
iOS needs its gate for a reason Kotlin does not have: `newRole` is `@State`
initialised to a placeholder `.member` and corrected inside `.task`
(GroupMemberInfoView.swift:30, :297), so the role picker must not render before
that runs. Kotlin seeds it from the member inline -
`remember { mutableStateOf(member.memberRole) }` - and is correct on the first
frame.
### The chat-change guard is kept
`#7388` added `chatModel.chatId.value != groupInfo.id` so that a load finishing
after the chat changed does not write to the model. The modal now opens before
the guard is reached, and `KeyChangeEffect` (:56) closes the card on chat change
as before, so the guard is left exactly as it was.
`groupMembersJob.cancel()` is kept in the tap handler: `info` (ChatView.kt:390)
preloads members *before* showing its modal, so without the cancel a pending chat
info load would open its modal on top of the member card.
## Consequences
- The card renders from the member known at the tap, so it can be marginally
staler for the duration of the load. iOS has the same window - it renders the
header before `connectionLoaded`.
- "Verify security code" and the Servers section are shown disabled until the
queries return, so the card does not change height as they arrive. This is a
third behaviour, matching neither the old Kotlin (rows inserted as data landed)
nor iOS (whole body hidden until loaded); porting it to iOS would be the way to
converge them.
- "Fix connection" still appears on arrival when the ratchet needs syncing. It is
rare enough that reserving space for it would move the card more often than it
saves.
- "Send message" for a member with `!sendMsgEnabled` does nothing if tapped
before the stats arrive. iOS is identical: `createMemberContactButton` has the
same `else if let connStats` with no else, and renders the action buttons
before `connectionLoaded`.
- The modal is now opened from the click handler on the main thread instead of
from `Dispatchers.Default` / `withBGApi`. All three callers are UI click
handlers, so this is strictly safer - `modalViews` is a plain `ArrayList`.
## Out of Scope
- **Collapsing the three copies of the fetch.** The same two queries and the same
condition are still written out at all three call sites. That duplication has
already drifted once - `#7255` widened the condition for channel members in two
of the three and in iOS, and `ChatView.kt` was left behind until `#7486` - and
moving the load into the view, as iOS has done since `#5008`, would remove it.
It is a restructure rather than a fix, so it belongs in its own change.
- Message *Info* (ChatView.kt:706) still loads all members; it needs them to
resolve delivery recipients. A narrower core API would be required.
- Opening a second member's profile while one is already open does not switch the
card: `closeModals()` sets `modalCount` to 0 and `showCustomModal` back to 1, so
`AnimatedContent`'s target never changes. The close and open are back to back in
both the old and the new code, so this is neither introduced nor worsened.
@@ -0,0 +1,86 @@
# Fix Android app opening simplex.chat web pages as connection links
## Problem
Tapping `https://simplex.chat/crowdfunding/` in a chat and confirming "Open" in the
"Open web link?" alert shows "Invalid connection link" instead of opening the page
in the browser. `/call/`, `/credits/` and `/about/` are affected the same way. iOS and
desktop are not.
## Cause
"Open" calls `safeOpenUri` → `uriHandler.openUri` (`TextItemView.kt`), which on Android
fires an `ACTION_VIEW` intent. The app's own verified app-link filter in
`android/src/main/AndroidManifest.xml` matches the `simplex.chat` host with:
```xml
<data android:pathPrefix="/invitation" />
<data android:pathPrefix="/contact" />
<data android:pathPrefix="/a" />
<data android:pathPrefix="/c" />
<data android:pathPrefix="/g" />
<data android:pathPrefix="/i" />
```
`pathPrefix` is a plain string prefix, so `/c` also matches `/crowdfunding/`, `/call/`
and `/credits/`, and `/a` matches `/about/`. The single-letter prefixes were added
for short links in #5824. Android delivers the intent back to SimpleX, `processIntent`
sets `appOpenUrl`, and `connectIfOpenedViaUri` → `planAndConnect` tries to connect via
the web page URL and fails.
iOS is not affected: `website/src/.well-known/apple-app-site-association` lists exact
components (`/c` and `/c/*`, etc.), not string prefixes. Desktop registers no https
handler.
## Fix
Make the Android filter match the iOS one: replace each `pathPrefix="/X"` with an exact
`path="/X"` plus `pathPrefix="/X/"`.
```xml
<data android:path="/c" />
<data android:pathPrefix="/c/" />
```
Connection links carry their data in the fragment (`/contact#/?v=…`, `/a#key`), which
is not part of the matched path, so they still match the exact path. Links with a
trailing slash or a subpath (`/contact/#…`, `/c/…`) match the `/X/` prefix, the same as
`/X/*` on iOS.
App-link verification (`autoVerify`, `assetlinks.json`) is per host and is not changed
by the path list.
## Alternatives considered
- Detecting non-connection URLs in `processIntent` and re-opening them in the browser:
a second `ACTION_VIEW` resolves back to the same filter, so the app would need an
explicit browser component. That still sends every such link through SimpleX first,
including links tapped in other apps.
- `pathPattern`: it has no anchors or alternation that `path` + `pathPrefix` doesn't
already cover, and it is harder to read.
## Verification
Checked on an API 27 emulator using stub APKs that contain only the app's intent
filter, with `cmd package query-activities -a android.intent.action.VIEW -c
android.intent.category.BROWSABLE -d <url>`:
| URL | before | after |
|---|---|---|
| `https://simplex.chat/crowdfunding/` | SimpleX + browser | browser |
| `https://simplex.chat/call/` | SimpleX + browser | browser |
| `https://simplex.chat/credits/` | SimpleX + browser | browser |
| `https://simplex.chat/about/` | SimpleX + browser | browser |
| `https://simplex.chat/contacts` | SimpleX + browser | browser |
| `https://simplex.chat/blog/` | browser | browser |
| `https://simplex.chat/contact#/?v=1` | SimpleX + browser | SimpleX + browser |
| `https://simplex.chat/contact/#/?v=1` | SimpleX + browser | SimpleX + browser |
| `https://simplex.chat/invitation#/?v=1` | SimpleX + browser | SimpleX + browser |
| `https://smp4.simplex.im/a#abc` | SimpleX + browser | SimpleX + browser |
| `https://simplex.chat/c#abc` | SimpleX + browser | SimpleX + browser |
| `https://simplex.chat/c/abc` | SimpleX + browser | SimpleX + browser |
| `https://simplex.chat/g#x` | SimpleX + browser | SimpleX + browser |
| `https://simplex.chat/i#x` | SimpleX + browser | SimpleX + browser |
The built arm64 debug APK's manifest was dumped with `aapt2` and contains the new
`path` / `pathPrefix` entries.
@@ -0,0 +1,49 @@
# Desktop: app freezes when a video is stopped while its format is being negotiated
## Problem
On desktop, playing a video in the chat and then opening it fullscreen (sometimes after a few attempts) leaves the fullscreen view black. The whole app stops responding: the back button, other views and the window close button all do nothing. A process restart is the only way out.
It reproduces with a video that makes VLC renegotiate its output format repeatedly (the log is full of `main filter error: Failed to create video converter`). Test videos that negotiate once did not reproduce it.
## Evidence
Reproduced on a build of `master` (VLC 3.0.23 bundled) by looping: play inline, click to stop, click to open fullscreen, back. It froze on the second iteration. Two `jcmd Thread.print` dumps taken 2 s apart were identical:
| Thread | Where it is |
|---|---|
| `pool-3-thread-1` (`VideoPlayer.playerThread`) | `libvlc_media_player_stop` ← `VideoPlayer.stop()` (`VideoPlayer.desktop.kt:117`) |
| `Thread-10825` (vlc decoder thread in a JNA callback) | `libvlc_video_get_track` ← `SkiaBitmapBufferFormatCallback.getBufferFormat` (`SkiaBitmapVideoSurface.kt:83`, `player.video().track()`) |
| `AWT-EventQueue-0` | `libvlc_media_player_get_time` ← `VideoPlayer.currentPosition` ← progress coroutine on `Dispatchers.Main` (`VideoPlayer.desktop.kt:94`) |
The JVM needs `-XX:+StartAttachListener` for `jcmd` to attach, because the Haskell RTS takes over `SIGQUIT`.
## Cause
In VLC 3.0.23:
- `libvlc_media_player_stop` (`lib/media_player.c:1065`) takes `lock_input` and, while holding it, calls `release_input_thread`, which ends in `input_Stop` + `input_Close` (`:183-184`). These wait for the input thread, and its decoder threads, to exit.
- `libvlc_video_get_track` (`lib/video.c:628`) and `libvlc_media_player_get_time` (`lib/media_player.c:1358`) both go through `libvlc_get_input_thread`, which takes the same `lock_input` (`lib/media_player.c:198`).
`getBufferFormat` runs on the decoder thread during format negotiation. When `stop()` arrives at that moment, `stop` holds the lock and waits for the decoder thread, and the decoder thread waits for the lock in `video().track()`. The UI thread's 50 ms `get_time` poll then blocks on the same lock, which freezes the app.
The `track()` call was added in #7391, which fixed stretched AV1 playback by using the track size instead of the padded size that libvlc passes.
## Fix
Stop calling `video().track()` in `getBufferFormat`. Take the track from `media().info().videoTracks()` only, using the `singleOrNull()` fallback the code already used before VLC had selected a track.
Why the remaining calls cannot deadlock the same way:
- `release_input_thread` is called only from `stop` (`:1066`) and `set_media` (`:886`), which hold only `lock_input` while waiting, and from `destroy` (`:819`), which holds no lock.
- `media()` → `libvlc_media_player_get_media` takes `object_lock` (`:922`), which is never held while waiting for the input thread.
- `info()` → `libvlc_media_tracks_get` takes only the media item's lock (`lib/media.c:967`).
Behaviour change: none for a file with one video track, which is every video SimpleX sends. A file with several video tracks gets the size libvlc passed, as it did before #7391.
## Alternatives considered
- **No libvlc calls in the callback at all.** Read the track size on `playerThread` before playback and hand it to the surface. This is more code and adds a metadata read before every play, and the lock analysis above shows it buys nothing for this deadlock.
- **Crop the padded frame instead of asking for the track size.** This does not work: vlc scales the picture to whatever size the callback returns, which is why AV1 was stretched before #7391.
- **Size from vlcj elementary-stream events.** These can arrive after the format has been negotiated, so the first frames would use the padded size.
- **Moving the progress polling and click handlers' libvlc calls off the UI thread.** This would stop any future libvlc stall from freezing the whole app, but it is a separate change across several files and is left out of this bugfix.
+47
View File
@@ -0,0 +1,47 @@
# Desktop: crash at startup on Windows when WMI does not answer
Fixes #7580.
## Problem
The desktop app exits at startup on some Windows 10 machines:
```
java.lang.ExceptionInInitializerError
at com.jthemedetecor.OsThemeDetector.getDetector(OsThemeDetector.java:43)
at chat.simplex.common.platform.Resources_desktopKt.<clinit>(Resources.desktop.kt:29)
...
Caused by: java.lang.NullPointerException: Cannot invoke "String.compareTo(String)" because "buildNumber" is null
at oshi.software.os.OperatingSystem$OSVersionInfo.<init>(OperatingSystem.java:510)
at oshi.software.os.windows.WindowsOperatingSystem.queryFamilyVersionInfo(WindowsOperatingSystem.java:172)
...
at com.jthemedetecor.util.OsInfo.<clinit>(OsInfo.java:27)
```
## Cause
`jSystemThemeDetector:3.8` depends on `oshi-core:5.8.6`. Its `OsInfo` static initialiser calls `OperatingSystem.getVersionInfo()`.
In oshi 5.8.6, `WindowsOperatingSystem.queryFamilyVersionInfo()` starts with `buildNumber = null` and sets it only if the WMI query for `Win32_OperatingSystem` returns a row. `WmiQueryHandler.queryWMI` returns an empty result on any `COMException` (invalid namespace/class, access denied, broken WMI repository or service) or on a timeout. The `OSVersionInfo` constructor then runs:
```java
if ("10".equals(version) && buildNumber.compareTo("22000") >= 0) {
```
It throws when `os.name` is "Windows 10" and WMI returned nothing. So only machines with a failing WMI query crash; a healthy WMI returns the build number.
The app calls `OsThemeDetector.getDetector()` in top-level `val` initialisers (`Resources.desktop.kt`, `Theme.desktop.kt`). The failure is an `Error` raised while the class loads, so nothing catches it. `settings` lives in the same file, so the first preference read in `runMigrations` fails and the app exits.
## Fix
Add `com.github.oshi:oshi-core:6.4.13` to `desktopMain`. Gradle conflict resolution replaces the transitive 5.8.6 with it.
- The `buildNumber.compareTo` check is in every oshi release up to 6.3.0 and was removed in 6.3.1. There the constructor just stores the null build number.
- 6.4.13 is built against JNA 5.14.0, the version the app already pins (`jna`, `jna-platform`). 6.3.1 used 5.12.1 and 6.6.x uses 5.16.0.
- jSystemThemeDetector 3.8 uses only `SystemInfo.<init>`, `getOperatingSystem`, `getCurrentPlatform`, `OperatingSystem.getFamily`, `getVersionInfo`, `OSVersionInfo.getVersion` and `PlatformEnum.{WINDOWS,MACOS,LINUX}`. All of them exist unchanged in 6.4.13.
## Verification
- `new OSVersionInfo("10", "", null)` throws the reported NPE with oshi 5.8.6 and returns `10` with 6.4.13.
- With 6.4.13, jSystemThemeDetector 3.8 and JNA 5.14.0 on the classpath, `OsThemeDetector.getDetector()` loads and returns a detector.
- `:common:dependencyInsight --configuration desktopRuntimeClasspath --dependency oshi-core` shows `5.8.6 -> 6.4.13`.
+1 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 7.1.0.7
version: 7.1.0.8
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -144,7 +144,7 @@ SEARCH r USING INDEX idx_rcv_file_chunk_replicas_rcv_file_chunk_id (rcv_file_chu
SEARCH s USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT c.corr_id, cs.user_id, c.command
SELECT c.corr_id, cs.user_id, c.command, c.created_at
FROM commands c
JOIN connections cs USING (conn_id)
WHERE c.command_id = ?
@@ -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=?)