Merge branch 'stable'

This commit is contained in:
Evgeny Poberezkin
2026-05-28 23:19:54 +01:00
35 changed files with 1023 additions and 222 deletions
@@ -4711,6 +4711,7 @@ sealed class Format {
val viaHosts: String get() =
"(${String.format(generalGetString(MR.strings.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})"
}
@Serializable @SerialName("simplexName") class SimplexName(val nameInfo: SimplexNameInfo): Format()
@Serializable @SerialName("command") class Command(val commandStr: String): Format()
@Serializable @SerialName("mention") class Mention(val memberName: String): Format()
@Serializable @SerialName("email") class Email: Format()
@@ -4728,6 +4729,7 @@ sealed class Format {
is Uri -> linkStyle
is HyperLink -> linkStyle
is SimplexLink -> linkStyle
is SimplexName -> linkStyle
is Command -> SpanStyle(color = MaterialTheme.colors.primary, fontFamily = FontFamily.Monospace)
is Mention -> SpanStyle(fontWeight = FontWeight.Medium)
is Email -> linkStyle
@@ -4759,6 +4761,27 @@ enum class SimplexLinkType(val linkType: String) {
})
}
@Serializable
data class SimplexNameInfo(
val nameType: SimplexNameType,
val nameTLD: SimplexTLD,
val domain: String,
val subDomain: List<String>
)
@Serializable
enum class SimplexTLD {
@SerialName("simplex") simplex,
@SerialName("testing") testing,
@SerialName("web") web
}
@Serializable
enum class SimplexNameType {
@SerialName("publicGroup") publicGroup,
@SerialName("contact") contact
}
@Serializable
enum class FormatColor(val color: String) {
red("red"),
@@ -6994,6 +6994,7 @@ sealed class GroupLinkPlan {
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan()
@Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan()
@Serializable @SerialName("noRelays") class NoRelays(val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan()
@Serializable @SerialName("updateRequired") class UpdateRequired(val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan()
}
abstract class TerminalItem {
@@ -281,6 +281,13 @@ fun MarkdownText (
}
}
}
is Format.SimplexName -> {
hasLinks = true
val ftStyle = Format.linkStyle
withAnnotation(tag = "SIMPLEX_NAME", annotation = i.toString()) {
withStyle(ftStyle) { append(ft.text) }
}
}
is Format.Email -> {
hasLinks = true
val ftStyle = Format.linkStyle
@@ -329,6 +336,16 @@ fun MarkdownText (
withAnnotation("WEB_URL") { a -> openBrowserAlert(a.item, uriHandler) }
withAnnotation("OTHER_URL") { a -> safeOpenUri(a.item, uriHandler) }
withAnnotation("SIMPLEX_URL") { a -> uriHandler.openVerifiedSimplexUri(a.item) }
withAnnotation("SIMPLEX_NAME") { a ->
val idx = a.item.toIntOrNull()
val nameInfo = (idx?.let { formattedText.getOrNull(it) }?.format as? Format.SimplexName)?.nameInfo
val (title, msg) = if (nameInfo?.nameType == SimplexNameType.contact) {
generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version)
} else {
generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version)
}
AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}")
}
}
if (hasSecrets) {
withAnnotation("SECRET") { a ->
@@ -343,7 +360,7 @@ fun MarkdownText (
onHover = { offset ->
val hasAnnotation: (String) -> Boolean = { tag -> annotatedText.hasStringAnnotations(tag, start = offset, end = offset) }
icon.value =
if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) {
if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) {
PointerIcon.Hand
} else {
PointerIcon.Text
@@ -792,31 +792,29 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
val link = strHasSingleSimplexLink(it.trim())
if (link != null) {
// if SimpleX link is pasted, show connection dialogue
hideKeyboard(view)
if (link.format is Format.SimplexLink) {
val linkText = link.format.simplexLinkText
searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero)
when (val target = strConnectTarget(it.trim())) {
is ConnectTarget.Link -> {
hideKeyboard(view)
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(target.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
}
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(link.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
// if some other text is pasted, enter search mode
focusRequester.requestFocus()
} else {
if (!chatModel.appOpenUrlConnecting.value) {
connectProgressManager.cancelConnectProgress()
}
if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
} else {
if (!chatModel.appOpenUrlConnecting.value) {
connectProgressManager.cancelConnectProgress()
}
if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
}
}
@@ -30,14 +30,23 @@ suspend fun planAndConnect(
filterKnownContact: ((Contact) -> Unit)? = null,
filterKnownGroup: ((GroupInfo) -> Unit)? = null,
): CompletableDeferred<Boolean> {
val link = strHasSingleSimplexLink(shortOrFullLink.trim())
if (link?.format is Format.SimplexLink && (link.format as Format.SimplexLink).linkType == SimplexLinkType.relay) {
AlertManager.privacySensitive.showAlertMsg(
generalGetString(MR.strings.relay_address_alert_title),
generalGetString(MR.strings.relay_address_alert_message),
)
cleanup?.invoke()
return CompletableDeferred(false)
when (val target = strConnectTarget(shortOrFullLink.trim())) {
is ConnectTarget.Name -> {
showUnsupportedNameAlert(target.nameInfo)
cleanup?.invoke()
return CompletableDeferred(false)
}
is ConnectTarget.Link -> {
if (target.linkType == SimplexLinkType.relay) {
AlertManager.privacySensitive.showAlertMsg(
generalGetString(MR.strings.relay_address_alert_title),
generalGetString(MR.strings.relay_address_alert_message),
)
cleanup?.invoke()
return CompletableDeferred(false)
}
}
null -> {}
}
connectProgressManager.cancelConnectProgress()
val inProgress = mutableStateOf(true)
@@ -73,11 +82,8 @@ private suspend fun planAndConnectTask(
if (!inProgress.value) { return completable }
if (result != null) {
val (connectionLink, connectionPlan) = result
val link = strHasSingleSimplexLink(shortOrFullLink.trim())
val linkText = if (link?.format is Format.SimplexLink)
"<br><br><u>${link.format.simplexLinkText}</u>"
else
""
val target = strConnectTarget(shortOrFullLink.trim())
val linkText = if (target is ConnectTarget.Link) "<br><br><u>${target.linkText}</u>" else ""
when (connectionPlan) {
is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) {
is InvitationLinkPlan.Ok ->
@@ -316,6 +322,33 @@ private suspend fun planAndConnectTask(
cleanup()
}
}
is GroupLinkPlan.UpdateRequired -> {
Log.d(TAG, "planAndConnect, .GroupLink, .UpdateRequired")
val groupSLinkData = connectionPlan.groupLinkPlan.groupSLinkData_
if (groupSLinkData != null) {
AlertManager.privacySensitive.showOpenChatAlert(
profileName = groupSLinkData.groupProfile.displayName,
profileFullName = groupSLinkData.groupProfile.fullName,
profileImage = {
ProfileImage(
size = alertProfileImageSize,
image = groupSLinkData.groupProfile.image,
icon = MR.images.ic_supervised_user_circle_filled
)
},
subtitle = generalGetString(MR.strings.group_link_requires_newer_version),
confirmText = null,
dismissText = generalGetString(MR.strings.ok),
onDismiss = { cleanup() }
)
} else {
AlertManager.privacySensitive.showAlertMsg(
generalGetString(MR.strings.app_update_required),
generalGetString(MR.strings.group_link_requires_newer_version)
)
cleanup()
}
}
}
is ConnectionPlan.Error -> {
Log.d(TAG, "planAndConnect, error ${connectionPlan.chatError}")
@@ -523,34 +523,32 @@ private fun ContactsSearchBar(
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
val link = strHasSingleSimplexLink(it.trim())
if (link != null) {
// if SimpleX link is pasted, show connection dialogue
hideKeyboard(view)
if (link.format is Format.SimplexLink) {
val linkText = link.format.simplexLinkText
searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero)
when (val target = strConnectTarget(it.trim())) {
is ConnectTarget.Link -> {
hideKeyboard(view)
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(
link = target.text,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
cleanup = { searchText.value = TextFieldValue() }
)
}
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(
link = link.text,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
cleanup = { searchText.value = TextFieldValue() }
)
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
// if some other text is pasted, enter search mode
focusRequester.requestFocus()
} else {
connectProgressManager.cancelConnectProgress()
if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
} else {
connectProgressManager.cancelConnectProgress()
if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
}
}
@@ -671,13 +671,14 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState<String>, showQRC
val clipboard = LocalClipboardManager.current
SectionItemView({
val str = clipboard.getText()?.text ?: return@SectionItemView
val link = strHasSingleSimplexLink(str.trim())
if (link != null) {
pastedLink.value = link.text
showQRCodeScanner.value = false
withBGApi { connect(rhId, link.text, close) { pastedLink.value = "" } }
} else {
AlertManager.shared.showAlertMsg(
when (val target = strConnectTarget(str.trim())) {
is ConnectTarget.Link -> {
pastedLink.value = target.text
showQRCodeScanner.value = false
withBGApi { connect(rhId, target.text, close) { pastedLink.value = "" } }
}
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
null -> AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_contact_link),
text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link)
)
@@ -819,12 +820,32 @@ fun strIsSimplexLink(str: String): Boolean {
return parsedMd != null && parsedMd.size == 1 && parsedMd[0].format is Format.SimplexLink
}
fun strHasSingleSimplexLink(str: String): FormattedText? {
val parsedMd = parseToMarkdown(str) ?: return null
val parsedLinks = parsedMd.filter { it.format?.isSimplexLink ?: false }
if (parsedLinks.size != 1) return null
sealed class ConnectTarget {
class Link(val text: String, val linkType: SimplexLinkType, val linkText: String) : ConnectTarget()
class Name(val nameInfo: SimplexNameInfo) : ConnectTarget()
}
return parsedLinks[0]
fun strConnectTarget(str: String): ConnectTarget? {
val parsedMd = parseToMarkdown(str) ?: return null
val links = parsedMd.filter { it.format?.isSimplexLink ?: false }
if (links.size == 1) {
val fmt = links[0].format as Format.SimplexLink
return ConnectTarget.Link(links[0].text, fmt.linkType, fmt.simplexLinkText)
}
if (links.isEmpty()) {
val nameInfo = parsedMd.firstNotNullOfOrNull { (it.format as? Format.SimplexName)?.nameInfo }
if (nameInfo != null) return ConnectTarget.Name(nameInfo)
}
return null
}
fun showUnsupportedNameAlert(nameInfo: SimplexNameInfo) {
val (title, msg) = if (nameInfo.nameType == SimplexNameType.contact) {
generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version)
} else {
generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version)
}
AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}")
}
@Composable
@@ -194,8 +194,15 @@
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Please check that you used the correct link or ask your contact to send you another one.</string>
<string name="unsupported_connection_link">Unsupported connection link</string>
<string name="link_requires_newer_app_version_please_upgrade">This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link.</string>
<string name="unsupported_channel_name">Unsupported channel name</string>
<string name="unsupported_contact_name">Unsupported contact name</string>
<string name="channel_name_requires_newer_app_version">Connecting via channel name requires a newer app version.</string>
<string name="contact_name_requires_newer_app_version">Connecting via contact name requires a newer app version.</string>
<string name="please_upgrade_the_app">Please upgrade the app.</string>
<string name="channel_temporarily_unavailable">Channel temporarily unavailable</string>
<string name="channel_no_active_relays_try_later">Channel has no active relays. Please try to join later.</string>
<string name="app_update_required">App update required</string>
<string name="group_link_requires_newer_version">This group requires a newer version of the app. Please update the app to join.</string>
<string name="connection_error_auth">Connection error (AUTH)</string>
<string name="connection_error_auth_desc">Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection.</string>
<string name="connection_error_blocked">Connection blocked</string>
@@ -10,7 +10,7 @@ val desktopPlatform = detectDesktopPlatform()
enum class DesktopPlatform(val libExtension: String, val configPath: String, val dataPath: String, val githubAssetName: String) {
LINUX_X86_64("so", unixConfigPath, unixDataPath, "simplex-desktop-x86_64.AppImage"),
LINUX_AARCH64("so", unixConfigPath, unixDataPath, " simplex-desktop-aarch64.AppImage"),
LINUX_AARCH64("so", unixConfigPath, unixDataPath, "simplex-desktop-aarch64.AppImage"),
WINDOWS_X86_64("dll", System.getenv("AppData") + File.separator + "SimpleX", System.getenv("AppData") + File.separator + "SimpleX", "simplex-desktop-windows-x86_64.msi"),
MAC_X86_64("dylib", unixConfigPath, unixDataPath, "simplex-desktop-macos-x86_64.dmg"),
MAC_AARCH64("dylib", unixConfigPath, unixDataPath, "simplex-desktop-macos-aarch64.dmg");
@@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface
import uk.co.caprica.vlcj.media.VideoOrientation
import uk.co.caprica.vlcj.player.base.*
import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent
@@ -214,7 +215,7 @@ actual class VideoPlayer actual constructor(
}
}
suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration = withContext(playerThread.asCoroutineDispatcher()) {
suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration = withContext(previewThread.asCoroutineDispatcher()) {
val mediaComponent = getOrCreateHelperPlayer()
val player = mediaComponent.mediaPlayer()
if (uri == null || !uri.toFile().exists()) {
@@ -222,12 +223,12 @@ actual class VideoPlayer actual constructor(
return@withContext VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L)
}
val surface = SkiaBitmapVideoSurface()
player.videoSurface().set(surface)
player.media().startPaused(uri.toFile().absolutePath)
val start = System.currentTimeMillis()
var snap: BufferedImage? = null
while (snap == null && start + 1500 > System.currentTimeMillis()) {
snap = player.snapshots()?.get()
delay(50)
val snap = withTimeoutOrNull(1500L) {
while (surface.bitmap.value == null) delay(50)
surface.bitmap.value!!.toAwtImage()
}
val orientation = player.media().info().videoTracks().firstOrNull()?.orientation()
if (orientation == null) {
@@ -255,6 +256,7 @@ actual class VideoPlayer actual constructor(
}
val playerThread = Executors.newSingleThreadExecutor()
private val previewThread = Executors.newSingleThreadExecutor()
private val playersPool: ArrayList<Component> = ArrayList()
private val helperPlayersPool: ArrayList<CallbackMediaPlayerComponent> = ArrayList()
@@ -26,6 +26,8 @@ import java.io.Closeable
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import kotlin.math.min
data class SemVer(
@@ -376,7 +378,7 @@ private fun chooseGitHubReleaseAssets(release: GitHubRelease): List<GitHubAsset>
val res = if (isRunningFromFlatpak()) {
// No need to show download options for Flatpak users
emptyList()
} else if (!isRunningFromAppImage() && Runtime.getRuntime().exec("which dpkg").onExit().join().exitValue() == 0) {
} else if (desktopPlatform.isLinux() && !isRunningFromAppImage() && Runtime.getRuntime().exec("which dpkg").onExit().join().exitValue() == 0) {
// Show all available .deb packages and user will choose the one that works on his system (for Debian derivatives)
release.assets.filter { it.name.lowercase().endsWith(".deb") }
} else {
@@ -388,18 +390,42 @@ private fun chooseGitHubReleaseAssets(release: GitHubRelease): List<GitHubAsset>
private suspend fun installAppUpdate(file: File) = withContext(Dispatchers.IO) {
when {
desktopPlatform.isLinux() -> {
val process = Runtime.getRuntime().exec("xdg-open ${file.absolutePath}").onExit().join()
val startedInstallation = process.exitValue() == 0 && process.children().count() > 0
if (!startedInstallation) {
Log.e(TAG, "Error starting installation: ${process.inputReader().use { it.readLines().joinToString("\n") }}${process.errorStream.use { String(it.readAllBytes()) }}")
// Failed to start installation. show directory with the file for manual installation
desktopOpenDir(file.parentFile)
val appImagePath = System.getenv("APPIMAGE")
if (appImagePath != null) {
// Replace the running AppImage crash-safely: copy onto the target's own
// filesystem first (an atomic rename only works within one filesystem, and
// the download lives in the temp dir which is usually a different one),
// then atomically move the staged file onto $APPIMAGE.
val target = File(appImagePath)
val staging = File(target.parentFile, ".${target.name}.update")
try {
Files.copy(file.toPath(), staging.toPath(), StandardCopyOption.REPLACE_EXISTING)
staging.setExecutable(true, false)
Files.move(staging.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
file.delete()
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.app_check_for_updates_installed_successfully_title),
text = generalGetString(MR.strings.app_check_for_updates_installed_successfully_desc)
)
} catch (e: Exception) {
Log.e(TAG, "Failed to replace AppImage: ${e.stackTraceToString()}")
staging.delete()
desktopOpenDir(file.parentFile)
}
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.app_check_for_updates_installed_successfully_title),
text = generalGetString(MR.strings.app_check_for_updates_installed_successfully_desc)
)
file.delete()
val process = Runtime.getRuntime().exec("xdg-open ${file.absolutePath}").onExit().join()
val startedInstallation = process.exitValue() == 0 && process.children().count() > 0
if (!startedInstallation) {
Log.e(TAG, "Error starting installation: ${process.inputReader().use { it.readLines().joinToString("\n") }}${process.errorStream.use { String(it.readAllBytes()) }}")
// Failed to start installation. show directory with the file for manual installation
desktopOpenDir(file.parentFile)
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.app_check_for_updates_installed_successfully_title),
text = generalGetString(MR.strings.app_check_for_updates_installed_successfully_desc)
)
file.delete()
}
}
}
desktopPlatform.isWindows() -> {