mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-02 15:41:44 +00:00
Merge branch 'master' into sh/namespace
This commit is contained in:
+18
-7
@@ -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
|
||||
)
|
||||
|
||||
+1
-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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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) }
|
||||
}
|
||||
|
||||
+7
-3
@@ -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() }
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+5
-9
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -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(
|
||||
|
||||
+35
-35
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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(
|
||||
|
||||
+21
-30
@@ -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))
|
||||
|
||||
+7
-2
@@ -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`
|
||||
Reference in New Issue
Block a user