Merge branch 'master' into sh/namespace

This commit is contained in:
Evgeny Poberezkin
2026-06-29 17:13:01 +01:00
12 changed files with 188 additions and 93 deletions
@@ -15,7 +15,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -30,7 +32,10 @@ import java.net.URI
@Composable
fun CIFileView(
file: CIFile?,
edited: Boolean,
meta: CIMeta,
chatTTL: Int?,
showViaProxy: Boolean,
showTimestamp: Boolean,
showMenu: MutableState<Boolean>,
smallView: Boolean = false,
senderProfile: LocalProfile?,
@@ -202,10 +207,13 @@ fun CIFileView(
) {
fileIndicator()
if (!smallView) {
val metaReserve = if (edited)
" "
else
" "
val secondaryColor = MaterialTheme.colors.secondary
val encrypted = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null
val metaReserve = buildAnnotatedString {
withStyle(reserveTimestampStyle) {
append(reserveSpaceForMeta(meta, chatTTL, encrypted, secondaryColor = secondaryColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp))
}
}
if (file != null) {
Column {
Text(
@@ -213,8 +221,11 @@ fun CIFileView(
maxLines = 1
)
Text(
formatBytes(file.fileSize) + metaReserve,
color = MaterialTheme.colors.secondary,
buildAnnotatedString {
append(formatBytes(file.fileSize))
append(metaReserve)
},
color = secondaryColor,
fontSize = 14.sp,
maxLines = 1
)
@@ -201,7 +201,7 @@ fun FramedItemView(
@Composable
fun ciFileView(ci: ChatItem, text: String) {
CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile)
CIFileView(ci.file, ci.meta, chatTTL, showViaProxy, showTimestamp, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile)
if (text != "" || ci.meta.isLive) {
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
@@ -342,7 +342,7 @@ fun ChatPreviewView(
}
}
is MsgContent.MCFile -> SmallContentPreviewFile {
CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
CIFileView(ci.file, ci.meta, cInfo.timedMessagesTTL, showViaProxy = false, showTimestamp = true, showMenu = remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
val user = chatModel.currentUser.value ?: return@CIFileView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
@@ -1,4 +1,5 @@
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -27,7 +28,7 @@ import chat.simplex.common.views.onboarding.SelectableCard
import chat.simplex.common.views.usersettings.SettingsActionItemWithContent
import chat.simplex.res.MR
private val SectionCardShape = RoundedCornerShape(16.dp)
val SectionCardShape = RoundedCornerShape(16.dp)
val CARD_PADDING = 18.dp
val ICON_TEXT_SPACING = 8.dp
@@ -113,15 +114,18 @@ fun SectionView(
iconTint: Color = MaterialTheme.colors.secondary,
leadingIcon: Boolean = false,
padding: PaddingValues = PaddingValues(),
onIconClick: (() -> Unit)? = null,
content: (@Composable ColumnScope.() -> Unit)
) {
val card = LocalCardScreen.current
Column {
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
val interactionSource = remember { MutableInteractionSource() }
val iconClickable = if (onIconClick != null) Modifier.clickable(interactionSource = interactionSource, indication = ripple(bounded = false, radius = iconSize * 0.75f), onClick = onIconClick) else Modifier
Row(Modifier.padding(start = if (card) DEFAULT_PADDING + DEFAULT_PADDING_HALF else DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) {
if (leadingIcon) Icon(icon, null, Modifier.padding(end = DEFAULT_PADDING_HALF).size(iconSize), tint = iconTint)
if (leadingIcon) Icon(icon, null, Modifier.padding(end = DEFAULT_PADDING_HALF).size(iconSize).then(iconClickable), tint = iconTint)
Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = if (card) 14.sp else 12.sp, fontWeight = if (card) FontWeight.Medium else FontWeight.Normal)
if (!leadingIcon) Icon(icon, null, Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize), tint = iconTint)
if (!leadingIcon) Icon(icon, null, Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize).then(iconClickable), tint = iconTint)
}
CardColumn(padding) { content() }
}
@@ -31,6 +31,7 @@ fun TextEditor(
modifier: Modifier,
placeholder: String? = null,
contentPadding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING),
shape: Shape = RoundedCornerShape(14.dp),
isValid: (String) -> Boolean = { true },
focusRequester: FocusRequester? = null,
enabled: Boolean = true
@@ -53,7 +54,7 @@ fun TextEditor(
.fillMaxWidth()
.padding(contentPadding)
.heightIn(min = 52.dp)
.border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(14.dp)),
.border(border = BorderStroke(1.dp, strokeColor), shape = shape),
contentAlignment = Alignment.Center,
) {
val textFieldModifier = modifier
@@ -148,7 +148,6 @@ private fun ConnectingDesktop(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) {
AppBarTitle(stringResource(MR.strings.connecting_to_desktop))
SectionView(stringResource(MR.strings.connecting_to_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
CtrlDeviceNameText(session, rc)
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
CtrlDeviceVersionText(session)
}
@@ -257,7 +256,6 @@ private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessC
AppBarTitle(stringResource(MR.strings.verify_connection))
SectionView(stringResource(MR.strings.connected_to_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
CtrlDeviceNameText(session, rc)
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
CtrlDeviceVersionText(session)
}
@@ -265,16 +263,15 @@ private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessC
SectionView(stringResource(MR.strings.verify_code_with_desktop)) {
SessionCodeText(sessCode)
SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }) {
Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.confirm_verb), tint = MaterialTheme.colors.secondary)
TextIconSpaced(false)
Text(generalGetString(MR.strings.confirm_verb))
}
}
SectionDividerSpaced()
SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }) {
Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.confirm_verb), tint = MaterialTheme.colors.secondary)
TextIconSpaced(false)
Text(generalGetString(MR.strings.confirm_verb))
}
SectionView {
DisconnectButton(onClick = ::disconnectDesktop)
}
@@ -312,7 +309,6 @@ private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo, close:
AppBarTitle(stringResource(MR.strings.connected_to_desktop))
SectionView(stringResource(MR.strings.connected_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(rc.deviceViewName)
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
CtrlDeviceVersionText(session)
}
@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.appPlatform
import chat.simplex.res.MR
@Composable
@@ -38,12 +39,14 @@ fun CallSettingsLayout(
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.your_calls))
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
SectionItemView(editIceServers) { Text(stringResource(MR.strings.webrtc_ice_servers)) }
val enabled = remember { mutableStateOf(true) }
LockscreenOpts(lockCallState, enabled, onSelected = { callOnLockScreen.set(it); lockCallState.value = it })
if (appPlatform.isAndroid) {
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
val enabled = remember { mutableStateOf(true) }
LockscreenOpts(lockCallState, enabled, onSelected = { callOnLockScreen.set(it); lockCallState.value = it })
}
SettingsPreferenceItem(null, stringResource(MR.strings.always_use_relay), webrtcPolicyRelay)
}
SectionTextFooter(
@@ -665,46 +665,46 @@ fun SimplexLockView(
}
}
}
if (performLA.value && laMode.value == LAMode.PASSCODE) {
SectionDividerSpaced()
SectionView(stringResource(MR.strings.self_destruct_passcode)) {
val openInfo = {
ModalManager.start.showModal {
SelfDestructInfoView()
}
}
if (performLA.value && laMode.value == LAMode.PASSCODE) {
SectionDividerSpaced()
SectionView(stringResource(MR.strings.self_destruct_passcode)) {
val openInfo = {
ModalManager.start.showModal {
SelfDestructInfoView()
}
SettingsActionItemWithContent(null, null, click = openInfo) {
SharedPreferenceToggleWithIcon(
stringResource(MR.strings.enable_self_destruct),
painterResource(MR.images.ic_info),
openInfo,
remember { selfDestructPref.state }.value
) {
toggleSelfDestruct(selfDestructPref)
}
}
SettingsActionItemWithContent(null, null, click = openInfo) {
SharedPreferenceToggleWithIcon(
stringResource(MR.strings.enable_self_destruct),
painterResource(MR.images.ic_info),
openInfo,
remember { selfDestructPref.state }.value
) {
toggleSelfDestruct(selfDestructPref)
}
}
if (remember { selfDestructPref.state }.value) {
Column(Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) {
Text(
stringResource(MR.strings.self_destruct_new_display_name),
fontSize = 16.sp,
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
)
ProfileNameField(selfDestructDisplayName, "", { isValidDisplayName(it.trim()) })
LaunchedEffect(selfDestructDisplayName.value) {
val new = selfDestructDisplayName.value
if (isValidDisplayName(new) && selfDestructDisplayNamePref.get() != new) {
selfDestructDisplayNamePref.set(new)
}
if (remember { selfDestructPref.state }.value) {
Column(Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) {
Text(
stringResource(MR.strings.self_destruct_new_display_name),
fontSize = 16.sp,
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
)
ProfileNameField(selfDestructDisplayName, "", { isValidDisplayName(it.trim()) })
LaunchedEffect(selfDestructDisplayName.value) {
val new = selfDestructDisplayName.value
if (isValidDisplayName(new) && selfDestructDisplayNamePref.get() != new) {
selfDestructDisplayNamePref.set(new)
}
}
SectionItemView({ changeSelfDestructPassword() }) {
Text(
stringResource(MR.strings.change_self_destruct_passcode),
color = MaterialTheme.colors.primary
)
}
}
SectionItemView({ changeSelfDestructPassword() }) {
Text(
stringResource(MR.strings.change_self_destruct_passcode),
color = MaterialTheme.colors.primary
)
}
}
}
@@ -1,6 +1,7 @@
package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionCardShape
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
@@ -699,7 +700,13 @@ private fun AcceptIncognitoToggle(addressSettingsState: MutableState<AddressSett
@Composable
private fun AutoReplyEditor(addressSettingsState: MutableState<AddressSettingsState>) {
val autoReply = rememberSaveable { mutableStateOf(addressSettingsState.value.autoReply) }
TextEditor(autoReply, Modifier.height(100.dp), placeholder = stringResource(MR.strings.enter_welcome_message_optional))
TextEditor(
autoReply,
Modifier.height(100.dp),
placeholder = stringResource(MR.strings.enter_welcome_message_optional),
contentPadding = PaddingValues(),
shape = SectionCardShape
)
LaunchedEffect(autoReply.value) {
if (autoReply.value != addressSettingsState.value.autoReply) {
addressSettingsState.value = AddressSettingsState(
@@ -1,6 +1,7 @@
package chat.simplex.common.views.usersettings.networkAndServers
import SectionBottomSpacer
import SectionCardShape
import SectionDividerSpaced
import SectionItemView
import SectionItemViewSpaceBetween
@@ -10,9 +11,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.sp
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
@@ -230,43 +229,35 @@ private fun CustomRelay(
}
SectionView(
stringResource(MR.strings.your_relay_address).uppercase(),
stringResource(MR.strings.your_relay_address),
icon = painterResource(MR.images.ic_error),
iconTint = if (!validAddress.value) MaterialTheme.colors.error else Color.Transparent,
) {
TextEditor(
relayAddress,
Modifier.height(144.dp)
Modifier.height(144.dp),
contentPadding = PaddingValues(),
shape = SectionCardShape
)
}
SectionDividerSpaced(maxTopPadding = true)
Column {
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(MR.strings.your_relay_name).uppercase(),
color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp
)
IconButton(
onClick = { if (!validName.value) showInvalidRelayNameAlert(relayName) },
enabled = !validName.value,
modifier = Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize)
) {
Icon(
painterResource(MR.images.ic_error), null,
tint = if (!validName.value) MaterialTheme.colors.error else Color.Transparent
)
}
}
Column(Modifier.fillMaxWidth()) {
TextEditor(
relayName,
Modifier,
placeholder = generalGetString(MR.strings.enter_relay_name),
enabled = relay.value.tested != true
)
}
SectionView(
stringResource(MR.strings.your_relay_name),
icon = painterResource(MR.images.ic_error),
iconTint = if (!validName.value) MaterialTheme.colors.error else Color.Transparent,
onIconClick = if (!validName.value) {
{ showInvalidRelayNameAlert(relayName) }
} else null
) {
TextEditor(
relayName,
Modifier,
placeholder = generalGetString(MR.strings.enter_relay_name),
contentPadding = PaddingValues(),
shape = SectionCardShape,
enabled = relay.value.tested != true
)
}
if (relay.value.tested != true) {
SectionTextFooter(annotatedStringResource(MR.strings.test_relay_to_retrieve_name))
@@ -13,9 +13,14 @@ import java.io.File
import java.util.*
import kotlin.math.max
internal val vlcFactory: MediaPlayerFactory by lazy { MediaPlayerFactory() }
// Serialize the two factory constructions: each MediaPlayerFactory() runs VLC native discovery via
// a JDK ServiceLoader, which is not thread-safe. Building both factories concurrently (e.g. vlcFactory
// on the render thread while vlcPreviewFactory is built on the preview thread) corrupts the ServiceLoader
// enumeration and throws NoSuchElementException from CompoundEnumeration.nextElement.
private val vlcFactoryLock = Any()
internal val vlcFactory: MediaPlayerFactory by lazy { synchronized(vlcFactoryLock) { MediaPlayerFactory() } }
// No hardware acceleration - more secure for previews
internal val vlcPreviewFactory: MediaPlayerFactory by lazy { MediaPlayerFactory("--avcodec-hw=none") }
internal val vlcPreviewFactory: MediaPlayerFactory by lazy { synchronized(vlcFactoryLock) { MediaPlayerFactory("--avcodec-hw=none") } }
actual class RecorderNative: RecorderInterface {
private var player: MediaPlayer? = null
@@ -0,0 +1,77 @@
# Fix desktop crash when opening a video (VLC factory init race)
## Problem (user-facing)
Opening a video in full screen on desktop can crash the app with:
```
java.util.NoSuchElementException
at java.base/java.lang.CompoundEnumeration.nextElement
... java.util.ServiceLoader ...
at uk.co.caprica.vlcj.factory.discovery.provider.DirectoryProviderDiscoveryStrategy.getSupportedProviders
at uk.co.caprica.vlcj.factory.MediaPlayerFactory.<init>
at chat.simplex.common.platform.RecAndPlay_desktopKt.vlcFactory_delegate$lambda$0(RecAndPlay.desktop.kt:16)
```
The crash is intermittent and originates from the lazy initialization of the shared
`MediaPlayerFactory` while a video full-screen view is being composed.
## Cause
Each `MediaPlayerFactory()` constructor runs VLC native-library discovery, which iterates a
JDK `ServiceLoader` over `DiscoveryDirectoryProvider`. `ServiceLoader` and the underlying
`CompoundEnumeration` are **not thread-safe**: when two factory constructions run concurrently
on different threads, one enumeration reports `hasNext() == true` and then throws
`NoSuchElementException` from `nextElement()`.
There are two factories on the desktop:
- `vlcFactory` — used by the real audio/video players. Its lazy init is triggered on the
AWT/Compose render thread when a video is opened full screen
(`VideoPlayer.initializeMediaPlayerComponent` -> `RecAndPlay.desktop.kt:16`).
- `vlcPreviewFactory` (`--avcodec-hw=none`) — used by preview snapshot helpers, whose lazy init
runs on the dedicated `previewThread` (`VideoPlayer.getOrCreateHelperPlayer`).
The single-factory invariant established by #6739 ("use shared VLC media-player factory") was
the original protection against concurrent factory construction. #6924 reintroduced a second
factory (`vlcPreviewFactory`) for hardware-acceleration-free previews, reopening the race: the
render thread can construct `vlcFactory` while `previewThread` constructs `vlcPreviewFactory`,
producing two concurrent `ServiceLoader` discoveries and the crash.
## Fix
Serialize the two `MediaPlayerFactory()` constructions behind a shared lock so their
native-discovery / `ServiceLoader` runs can never overlap:
```kotlin
private val vlcFactoryLock = Any()
internal val vlcFactory: MediaPlayerFactory by lazy { synchronized(vlcFactoryLock) { MediaPlayerFactory() } }
internal val vlcPreviewFactory: MediaPlayerFactory by lazy { synchronized(vlcFactoryLock) { MediaPlayerFactory("--avcodec-hw=none") } }
```
Both factories are preserved, including the preview factory's `--avcodec-hw=none` option. The
lock guards only the one-time construction of each factory, so there is no steady-state
contention once both are built.
### Why this approach
- **Minimal and intent-preserving.** Keeps both factories (preview still needs
`--avcodec-hw=none`) and only adds serialization, restoring the no-concurrent-construction
guarantee that #6739 relied on.
- **Lazy-preserving.** Each factory is still built strictly on demand; the lock only matters in
the rare window where both initialize at the same time. A smaller diff (forcing
`vlcFactory` first inside the preview initializer) was rejected because it would eagerly
construct the main factory whenever a preview is generated and reads as dead code.
### Known trade-off
Because `vlcFactory` is initialized on the AWT/render thread, if `previewThread` is mid-construction
of `vlcPreviewFactory` the render thread can briefly block on the lock until native discovery
finishes. This replaces an intermittent crash with a rare, short stall — an acceptable trade.
A more thorough follow-up would either collapse to a single factory (passing `:avcodec-hw=none`
as a per-media option on preview prepare) or eagerly initialize both factories off the render
thread at startup.
## Scope
- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt`