Merge branch 'master' into ep/parallel-tests
No files matched your search
@@ -22,11 +22,11 @@ struct GetStakeBanner: View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Get a stake in SimpleX Chat!")
|
||||
Text("Invest in SimpleX Chat!")
|
||||
.font(.headline)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.lineLimit(2)
|
||||
Text("Invest on Wefunder from $100")
|
||||
Text("Crowdfunding on Wefunder")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
.lineLimit(2)
|
||||
|
||||
@@ -890,7 +890,7 @@ struct GetStakeView: View {
|
||||
var body: some View {
|
||||
ZoomablePageView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text(verbatim: "Get a stake in\nSimpleX Chat")
|
||||
Text(verbatim: "Invest in\nSimpleX Chat")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -13,6 +13,8 @@ import SimpleXChat
|
||||
|
||||
let simplexTeamURL = URL(string: "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im")!
|
||||
|
||||
let simplexNewsURL = URL(string: "simplex:/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA?h=smp18.simplex.im")!
|
||||
|
||||
let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||
|
||||
let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
|
||||
@@ -462,6 +464,15 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Section(header: Text("Contact").foregroundColor(theme.colors.secondary)) {
|
||||
settingsRow("antenna.radiowaves.left.and.right", color: theme.colors.secondary) {
|
||||
Button("Follow SimpleX Network News") {
|
||||
dismiss()
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.appOpenUrl = simplexNewsURL
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
settingsRow("number", color: theme.colors.secondary) {
|
||||
Button("Send questions and ideas") {
|
||||
dismiss()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
|
||||
// Android blurs the bars with a RenderEffect on the layer itself, so this is never called there.
|
||||
actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) = drawBar()
|
||||
@@ -0,0 +1,6 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
|
||||
// Draws the bars' background blurred. The drawing to blur is given in bar coordinates, whatever size the blur runs at.
|
||||
expect fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit)
|
||||
@@ -1,18 +1,18 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.views.helpers.KeyChangeEffect
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
expect fun Modifier.desktopOnExternalDrag(
|
||||
@@ -58,20 +58,23 @@ fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState<B
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun blurHidesMedia(enabled: Boolean, blurred: State<Boolean>): Boolean =
|
||||
enabled && blurred.value && remember { appPrefs.privacyMediaBlurRadius.state }.value > 0
|
||||
|
||||
@Composable
|
||||
fun Modifier.privacyBlur(
|
||||
enabled: Boolean,
|
||||
preview: ImageBitmap,
|
||||
blurred: MutableState<Boolean> = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) },
|
||||
scrollState: State<Boolean>,
|
||||
onLongClick: () -> Unit = {}
|
||||
): Modifier {
|
||||
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
|
||||
return if (enabled && blurred.value) {
|
||||
this then Modifier.blur(
|
||||
radiusX = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp,
|
||||
radiusY = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp,
|
||||
edgeTreatment = BlurredEdgeTreatment(RoundedCornerShape(0.dp))
|
||||
)
|
||||
return if (blurHidesMedia(enabled, blurred)) {
|
||||
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
|
||||
this then Modifier
|
||||
.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
|
||||
.combinedClickable(
|
||||
onLongClick = onLongClick,
|
||||
onClick = {
|
||||
@@ -90,3 +93,25 @@ fun Modifier.privacyBlur(
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
// Calibrated so the resample blurs as much as Modifier.blur did at each radius; 360 read 5-75% stronger.
|
||||
private const val BLURRED_MEDIA_WIDTH_DP = 400
|
||||
// Bounds the first step: nothing bounds a decoded video frame, and reading every pixel of a 4K one would stall.
|
||||
private const val RESAMPLE_MEDIA_FROM_SIDE = 512
|
||||
|
||||
private fun ImageBitmap.blurredBy(radius: Int): ImageBitmap {
|
||||
if (width <= 0 || height <= 0) return this
|
||||
val w = (BLURRED_MEDIA_WIDTH_DP / radius).coerceIn(1, width)
|
||||
val h = (w * height / width).coerceIn(1, BLURRED_MEDIA_WIDTH_DP)
|
||||
val longest = maxOf(width, height)
|
||||
var image = if (longest > RESAMPLE_MEDIA_FROM_SIDE) {
|
||||
val step = RESAMPLE_MEDIA_FROM_SIDE.toFloat() / longest
|
||||
scale((width * step).roundToInt().coerceAtLeast(1), (height * step).roundToInt().coerceAtLeast(1))
|
||||
} else this
|
||||
// One bilinear step from a large image undersamples it.
|
||||
while (image.width / 2 > w) image = image.scale(image.width / 2, (image.height / 2).coerceAtLeast(1))
|
||||
image = image.scale(w, h)
|
||||
// Stretching w px straight to the screen shows their grid; each doubling smooths it, so the last stretch is small.
|
||||
while (maxOf(image.width, image.height) < BLURRED_MEDIA_WIDTH_DP / 2) image = image.scale(image.width * 2, image.height * 2)
|
||||
return image
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -114,7 +114,7 @@ fun CIImageView(
|
||||
onClick = onClick
|
||||
)
|
||||
.onRightClick { showMenu.value = true }
|
||||
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
|
||||
.privacyBlur(!smallView, imageBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
|
||||
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
@@ -138,7 +138,7 @@ fun CIImageView(
|
||||
onClick = onClick
|
||||
)
|
||||
.onRightClick { showMenu.value = true }
|
||||
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
|
||||
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
|
||||
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
|
||||
)
|
||||
} else {
|
||||
@@ -149,7 +149,7 @@ fun CIImageView(
|
||||
onClick = {}
|
||||
)
|
||||
.onRightClick { showMenu.value = true }
|
||||
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
|
||||
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
imageView(previewBitmap, onClick = {
|
||||
@@ -195,22 +195,26 @@ fun CIImageView(
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
|
||||
if (chatModel.connectedToRemote()) {
|
||||
LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) {
|
||||
withBGApi {
|
||||
// Hidden media is not worth reading, decoding at full size and caching.
|
||||
val revealed = !blurHidesMedia(!smallView, blurred)
|
||||
if (revealed) {
|
||||
if (chatModel.connectedToRemote()) {
|
||||
LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) {
|
||||
withBGApi {
|
||||
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
|
||||
res.value = imageAndFilePath(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LaunchedEffect(file) {
|
||||
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
|
||||
res.value = imageAndFilePath(file)
|
||||
res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LaunchedEffect(file) {
|
||||
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
|
||||
res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val loaded = res.value
|
||||
val loaded = if (revealed) res.value else null
|
||||
if (loaded != null && file != null) {
|
||||
val (imageBitmap, data, _) = loaded
|
||||
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) })
|
||||
|
||||
@@ -421,7 +421,7 @@ fun VideoPreviewImageView(
|
||||
onClick = onClick
|
||||
)
|
||||
.onRightClick(onLongClick)
|
||||
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick),
|
||||
.privacyBlur(!smallView, preview, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick),
|
||||
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -779,7 +779,7 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
|
||||
} else {
|
||||
withBGApi {
|
||||
chatModel.appOpenUrlConnecting.value = true
|
||||
planAndConnect(rhId, uri, close = null, cleanup = { chatModel.appOpenUrlConnecting.value = false })
|
||||
planAndConnect(rhId, uri, close = { ModalManager.closeAllModalsEverywhere() }, cleanup = { chatModel.appOpenUrlConnecting.value = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.platform.appPlatform
|
||||
import chat.simplex.common.platform.drawBarsBlurred
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
|
||||
fun Modifier.blurredBackgroundModifier(
|
||||
@@ -32,7 +33,7 @@ fun Modifier.blurredBackgroundModifier(
|
||||
return if (appPlatform.isAndroid) {
|
||||
this.androidBlurredModifier(keyboardInset, blurRadius.value, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
|
||||
} else {
|
||||
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
|
||||
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, handler, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,40 +101,44 @@ private fun Modifier.desktopBlurredModifier(
|
||||
blurRadius: State<Int>,
|
||||
keyboardCoversBar: Boolean,
|
||||
onTop: Boolean,
|
||||
handler: AppBarHandler,
|
||||
graphicsLayer: GraphicsLayer,
|
||||
backgroundGraphicsLayer: GraphicsLayer,
|
||||
backgroundGraphicsLayerSize: State<IntSize>,
|
||||
density: Density
|
||||
): Modifier = this
|
||||
.graphicsLayer {
|
||||
renderEffect = if (blurRadius.value > 0) BlurEffect(blurRadius.value.dp.toPx(), blurRadius.value.dp.toPx()) else null
|
||||
clip = blurRadius.value > 0
|
||||
}
|
||||
.drawBehind {
|
||||
drawRect(CurrentColors.value.colors.background)
|
||||
if (onTop) {
|
||||
clipRect {
|
||||
if (backgroundGraphicsLayer.size != IntSize.Zero) {
|
||||
drawLayer(backgroundGraphicsLayer)
|
||||
} else {
|
||||
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
|
||||
val barWidth = size.width
|
||||
val barHeight = size.height
|
||||
// The blur is taken from a copy of the scrolled content rather than drawn from that layer, so unlike a layer's own
|
||||
// filter it does not follow the content on its own: the bar is redrawn when the container reports the copy moved.
|
||||
handler.contentVersion.value
|
||||
drawBarsBlurred(blurRadius.value.dp.toPx(), barWidth, barHeight) {
|
||||
drawRect(CurrentColors.value.colors.background, size = Size(barWidth, barHeight))
|
||||
if (onTop) {
|
||||
clipRect(0f, 0f, barWidth, barHeight) {
|
||||
if (backgroundGraphicsLayer.size != IntSize.Zero) {
|
||||
drawLayer(backgroundGraphicsLayer)
|
||||
} else {
|
||||
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
|
||||
}
|
||||
drawLayer(graphicsLayer)
|
||||
}
|
||||
drawLayer(graphicsLayer)
|
||||
}
|
||||
} else {
|
||||
val bgSize = when {
|
||||
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
|
||||
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
|
||||
else -> backgroundGraphicsLayerSize.value.height
|
||||
}
|
||||
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
|
||||
translate(top = -bgSize + size.height + keyboardHeightCovered) {
|
||||
if (backgroundGraphicsLayer.size != IntSize.Zero) {
|
||||
drawLayer(backgroundGraphicsLayer)
|
||||
} else {
|
||||
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
|
||||
} else {
|
||||
val bgSize = when {
|
||||
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
|
||||
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
|
||||
else -> backgroundGraphicsLayerSize.value.height
|
||||
}
|
||||
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
|
||||
translate(top = -bgSize + barHeight + keyboardHeightCovered) {
|
||||
if (backgroundGraphicsLayer.size != IntSize.Zero) {
|
||||
drawLayer(backgroundGraphicsLayer)
|
||||
} else {
|
||||
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
|
||||
}
|
||||
drawLayer(graphicsLayer)
|
||||
}
|
||||
drawLayer(graphicsLayer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,10 @@ class AppBarHandler(
|
||||
|
||||
val backgroundGraphicsLayerSize: MutableState<IntSize> = mutableStateOf(IntSize.Zero)
|
||||
|
||||
// Bars that blur a copy of the scrolled content depend on this to know the copy moved. It is bumped from a collector
|
||||
// rather than while drawing: a write made during the draw phase invalidates the bars every frame and never settles.
|
||||
val contentVersion: MutableState<Int> = mutableStateOf(0)
|
||||
|
||||
companion object {
|
||||
var appBarMaxHeightPx: Int = 0
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel
|
||||
|
||||
@Composable
|
||||
fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongClick: () -> Unit) {
|
||||
val image = base64ToBitmap(linkPreview.image)
|
||||
val image = remember(linkPreview.image) { base64ToBitmap(linkPreview.image) }
|
||||
Column(
|
||||
Modifier
|
||||
.layoutId(CHAT_IMAGE_LAYOUT_ID)
|
||||
@@ -207,7 +207,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongC
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.desktopModifyBlurredState(true, blurred, showMenu)
|
||||
.privacyBlur(true, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
|
||||
.privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) {
|
||||
|
||||
@@ -1033,7 +1033,7 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean {
|
||||
return v != lastVersion
|
||||
}
|
||||
|
||||
private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat"
|
||||
private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat?utm_source=app"
|
||||
|
||||
private const val CROWDFUNDING_CONTACT_URI = "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im"
|
||||
|
||||
@@ -1197,7 +1197,7 @@ fun GetStakeView(showFirstImage: Boolean, inCenterOfWindow: Boolean = false, clo
|
||||
|
||||
ColumnWithScrollBar(Modifier.pinchZoom().padding(horizontal = DEFAULT_PADDING)) {
|
||||
// in the center of the window the page is wide enough for the title to fit on one line
|
||||
val title = "Get a stake in\nSimpleX Chat"
|
||||
val title = "Invest in\nSimpleX Chat"
|
||||
AppBarTitle(if (inCenterOfWindow) title.replace("\n", " ") else title, withPadding = false)
|
||||
// What's new already shows the image of the first slide, above the link that opens this page
|
||||
if (showFirstImage) {
|
||||
|
||||
@@ -74,6 +74,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: (
|
||||
val simplexTeamUri =
|
||||
"simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im"
|
||||
|
||||
val simplexNewsUri =
|
||||
"simplex:/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA?h=smp18.simplex.im"
|
||||
|
||||
@Composable
|
||||
fun SettingsLayout(
|
||||
stopped: Boolean,
|
||||
@@ -161,6 +164,7 @@ fun HelpAndSupportView(
|
||||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_contact)) {
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_bigtop_updates), stringResource(MR.strings.follow_simplex_network_news), { uriHandler.openVerifiedSimplexUri(simplexNewsUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
|
||||
}
|
||||
SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
|
||||
|
||||
@@ -1015,6 +1015,7 @@
|
||||
<string name="markdown_in_messages">Markdown in messages</string>
|
||||
<string name="chat_with_the_founder">Send questions and ideas</string>
|
||||
<string name="send_us_an_email">Send us email</string>
|
||||
<string name="follow_simplex_network_news">Follow SimpleX Network News</string>
|
||||
<string name="chat_lock">SimpleX Lock</string>
|
||||
<string name="chat_console">Chat console</string>
|
||||
<string name="message_servers">Message servers</string>
|
||||
@@ -2678,8 +2679,8 @@
|
||||
<string name="v7_0_invest_descr" translatable="false">Crowdfunding on Wefunder.</string>
|
||||
<string name="v7_0_crowdfunding" translatable="false">Crowdfunding on Wefunder</string>
|
||||
<string name="v7_0_invest_learn_more" translatable="false">Learn more on Wefunder</string>
|
||||
<string name="invest_banner_title" translatable="false">Get a stake in SimpleX Chat!</string>
|
||||
<string name="invest_banner_subtitle" translatable="false">Invest on Wefunder from $100</string>
|
||||
<string name="invest_banner_title" translatable="false">Invest in SimpleX Chat!</string>
|
||||
<string name="invest_banner_subtitle" translatable="false">Crowdfunding on Wefunder</string>
|
||||
<string name="v7_0_simplex_names">SimpleX public names (BETA)</string>
|
||||
<string name="v7_0_simplex_names_descr">Public names for your channel or business.</string>
|
||||
<string name="v7_0_channels">Better channels 📢</string>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlurEffect
|
||||
import androidx.compose.ui.graphics.asComposeCanvas
|
||||
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.unit.Density
|
||||
import org.jetbrains.skia.*
|
||||
import kotlin.math.ceil
|
||||
|
||||
// Skia's raster blur costs width x (bar + 6 sigma) pixels every frame, which is what makes the bars lag. The blur cannot be
|
||||
// made cheaper by scaling the layer it runs on, because a layer's filter is evaluated in device space: any scale applied to
|
||||
// the layer scales the sigma straight back up, which weakens the blur without saving anything. So the bar is drawn into a
|
||||
// narrower offscreen surface and blurred there, at its own resolution, and only the finished blur is scaled back out.
|
||||
// Only the width is reduced: a bar is just AppBarHeight tall, and taking rows away from it visibly weakens the blur.
|
||||
private const val MAX_BLUR_NARROWING = 8
|
||||
private const val MIN_BLUR_WIDTH = 128
|
||||
|
||||
// How small the blur's own sigma is allowed to get. Below about this the narrowed copy is barely blurred, and stretching it
|
||||
// back out shows the steps of the narrowing instead of a blur.
|
||||
private const val MIN_NARROWED_SIGMA = 1.5f
|
||||
|
||||
// The rows are halved as well, with the vertical sigma halved to match, which is only sound because the copy is drawn at
|
||||
// that size rather than scaled afterwards. A bar has few rows to begin with, so it is not reduced past this.
|
||||
private const val MIN_BLUR_HEIGHT = 24
|
||||
|
||||
// The panes of the desktop window have different widths, so bars of several sizes are drawn in the same frame and a single
|
||||
// pair of surfaces would be reallocated for each of them. Keeping one pair per size, with the least recently drawn size
|
||||
// released once there are more than the window can show at once, allocates each pair once instead.
|
||||
private const val MAX_BAR_SURFACES = 4
|
||||
|
||||
private class BarBlurSurface(width: Int, height: Int) {
|
||||
val surface: Surface = Surface.makeRaster(ImageInfo.makeN32Premul(width, height))
|
||||
|
||||
fun close() = surface.close()
|
||||
}
|
||||
|
||||
private val barBlurSurfaces = ThreadLocal.withInitial { LinkedHashMap<Long, BarBlurSurface>() }
|
||||
|
||||
private fun surfaceFor(width: Int, height: Int): BarBlurSurface {
|
||||
val cached = barBlurSurfaces.get()
|
||||
val key = (width.toLong() shl 32) or height.toLong()
|
||||
val surface = cached.remove(key) ?: BarBlurSurface(width, height)
|
||||
cached[key] = surface
|
||||
while (cached.size > MAX_BAR_SURFACES) {
|
||||
cached.remove(cached.keys.first())?.close()
|
||||
}
|
||||
return surface
|
||||
}
|
||||
|
||||
private fun narrowing(sigma: Float, barWidth: Float): Int {
|
||||
var scale = 1
|
||||
while (sigma / (scale * 2) >= MIN_NARROWED_SIGMA && scale < MAX_BLUR_NARROWING && barWidth / (scale * 2) >= MIN_BLUR_WIDTH) scale *= 2
|
||||
return scale
|
||||
}
|
||||
|
||||
private fun shortening(sigma: Float, barHeight: Float): Int =
|
||||
if (sigma / 2 >= MIN_NARROWED_SIGMA && barHeight / 2 >= MIN_BLUR_HEIGHT) 2 else 1
|
||||
|
||||
actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) {
|
||||
val sigma = BlurEffect.convertRadiusToSigma(radiusPx)
|
||||
val scale = narrowing(sigma, barWidth)
|
||||
val rows = shortening(sigma, barHeight)
|
||||
val width = ceil(barWidth / scale).toInt()
|
||||
val height = ceil(barHeight / rows).toInt()
|
||||
if (width <= 0 || height <= 0) return
|
||||
val surface = surfaceFor(width, height).surface
|
||||
|
||||
surface.canvas.clear(Color.TRANSPARENT)
|
||||
// the blur is the layer's own paint, so it runs at this surface's resolution rather than at the canvas the bar draws to
|
||||
surface.canvas.saveLayer(Rect.makeWH(width.toFloat(), height.toFloat()), Paint().apply {
|
||||
imageFilter = ImageFilter.makeBlur(sigma / scale, sigma / rows, FilterTileMode.CLAMP)
|
||||
})
|
||||
CanvasDrawScope().draw(Density(density, fontScale), layoutDirection, surface.canvas.asComposeCanvas(), Size(width.toFloat(), height.toFloat())) {
|
||||
scale(1f / scale, 1f / rows, Offset.Zero) { drawBar() }
|
||||
}
|
||||
surface.canvas.restore()
|
||||
|
||||
// The surface is whole pixels while the bar is not, so only the bar's own area is taken from it.
|
||||
drawIntoCanvas {
|
||||
it.nativeCanvas.drawImageRect(
|
||||
surface.makeImageSnapshot(),
|
||||
Rect.makeWH(barWidth / scale, barHeight / rows),
|
||||
Rect.makeWH(barWidth, barHeight),
|
||||
SamplingMode.LINEAR,
|
||||
null,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,9 @@ actual fun LazyColumnWithScrollBar(
|
||||
}
|
||||
val state = state ?: handler.listState
|
||||
val connection = handler.connection
|
||||
LaunchedEffect(state, handler) {
|
||||
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }.collect { handler.contentVersion.value++ }
|
||||
}
|
||||
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on lazy column state
|
||||
// (only first visible row is useful because LazyColumn doesn't have absolute scroll position, only relative to row)
|
||||
val scrollBarDraggingState = remember { mutableStateOf(false) }
|
||||
@@ -202,6 +205,9 @@ actual fun ColumnWithScrollBar(
|
||||
}
|
||||
}
|
||||
val state = state ?: handler.scrollState
|
||||
LaunchedEffect(state, handler) {
|
||||
snapshotFlow { state.value }.collect { handler.contentVersion.value++ }
|
||||
}
|
||||
val connection = handler.connection
|
||||
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on column state
|
||||
// (exact scroll position is available but in Int, not Float)
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 171 KiB |
@@ -0,0 +1,126 @@
|
||||
# Blur media by resampling the preview
|
||||
|
||||
## Problem
|
||||
|
||||
"Blur media" is a per-frame effect. Every frame a blurred image or video preview is drawn, a
|
||||
Gaussian is convolved over an offscreen layer the size of the drawn media - about 500.dp wide, so
|
||||
roughly 1500x1125 px and a 6 MB render target on a 3x-density phone, for each blurred item on
|
||||
screen.
|
||||
|
||||
It also does nothing on Android 8.0 to 11: the setting reads Soft, Medium or Strong and the media
|
||||
is drawn sharp.
|
||||
|
||||
And the media the blur hides is still fetched in full. An image scrolled past is read from disk,
|
||||
decoded at its original resolution and kept in the image cache, only ever to be drawn as an
|
||||
unrecognisable smear.
|
||||
|
||||
## Cause
|
||||
|
||||
`Modifier.blur` compiles to `graphicsLayer { renderEffect = BlurEffect(...) }`. In
|
||||
`androidx.compose.ui:ui-android:1.8.2`, which is what this build resolves:
|
||||
|
||||
| layer implementation | API range | what `setRenderEffect` does |
|
||||
| -------------------- | --------- | ------------------------------------- |
|
||||
| `GraphicsLayerV23` | 23-28 | stores the field, never applies it |
|
||||
| `GraphicsLayerV29` | 29+ | applies only when `SDK_INT >= 31` |
|
||||
| `GraphicsViewLayer` | fallback | applies only when `SDK_INT >= 31` |
|
||||
|
||||
`minSdk` is 26, so on Android 8.0 to 11 the effect is silently dropped and the media is drawn
|
||||
unblurred. The codebase already knows this - `SimpleXAPI.kt` has
|
||||
`deviceSupportsBlur = ... androidApiLevel >= 32` - but that gates the app bar blur and the bar
|
||||
alpha, never the media blur.
|
||||
|
||||
The radius is in *display* pixels: `BlurKt` converts it with `toPx` on the `GraphicsLayerScope`
|
||||
before constructing the `BlurEffect`. The convolution therefore runs over the layer, not over the
|
||||
source bitmap - its cost does not depend on the source resolution, and it is paid again on every
|
||||
frame the layer is drawn.
|
||||
|
||||
The loading is independent of the blur. `CIImageView` calls `getLoadedImage`, which reads the file
|
||||
bytes and decodes a 1000 px bitmap, and then hands the same bytes to Coil at `Size.ORIGINAL`.
|
||||
`imageLoader` overrides neither `memoryCache` nor `memoryCachePolicy`, so Coil 2.6.0 installs its
|
||||
default memory cache with strong references and retains those full-resolution bitmaps across items.
|
||||
|
||||
## Fix
|
||||
|
||||
A blur and a downscale discard the same thing: detail finer than their radius. So the preview is
|
||||
resampled to about one pixel per radius, then doubled back up until its longest side is at least
|
||||
200 px, and drawn stretched to the item with the bilinear filtering `drawImage` uses by default.
|
||||
This runs once when the item composes rather than once per frame, needs no `RenderEffect`, and so
|
||||
works on every supported Android version.
|
||||
|
||||
The doubling is what makes it read as a blur rather than as pixels. Stretching a 30 px image
|
||||
straight to the screen interpolates between its pixels with a tent, and a tent 12 to 48 screen
|
||||
pixels wide shows the grid it sits on as soft squares and crosses. Each bilinear doubling
|
||||
convolves that tent with another one, so three or four doublings turn it into a bell close to the
|
||||
Gaussian that `Modifier.blur` drew, and the last stretch to the screen is short enough that no
|
||||
grid shows. Compose cannot do this at draw time: on Android `FilterQuality` only toggles
|
||||
`Paint.isFilterBitmap`, so `High` is still bilinear there, and only desktop maps it to a cubic
|
||||
resampler.
|
||||
|
||||
`BLURRED_MEDIA_WIDTH_DP` fixes the strength: it is the width the radius is measured against, so
|
||||
Soft, Medium and Strong resample to 400/12, 400/24 and 400/48, that is 33, 16 and 8 px wide. It is
|
||||
the one number to move if the result reads too soft or too sharp. It started at 360, the width the
|
||||
media is drawn at, and was calibrated to 400 by fitting: for each setting the pipeline's output was
|
||||
matched against Gaussians of every sigma to find the one it resembles most. At 360 the fitted sigma
|
||||
was 105%, 120% and 175% of the old blur's for Soft, Medium and Strong, the last because 7 px is too
|
||||
coarse to hold the picture; at 400 it is 105%, 110% and 95% on a 300 px preview and 90%, 95% and
|
||||
105% on a 100 px one. 440 sits at 80-95% across the board, so 400 is the closest match and 440 the
|
||||
next step lighter.
|
||||
|
||||
Resampling is also the better direction for a privacy control. A Gaussian is a convolution and is
|
||||
in principle partly invertible; a 7 px bitmap does not contain the detail to recover.
|
||||
|
||||
While the blur hides the media, the file is left unread - no disk read, no 1000 px decode, no
|
||||
full-resolution decode, no cache entry. `blurHidesMedia()` is the single definition that both the
|
||||
modifier drawing the blur and the decision to load the file consult, so the two cannot disagree
|
||||
about whether the media is on screen. Getting that wrong in either direction is a defect: one way
|
||||
the app shows what it promised to hide, the other it hides an image it will never load.
|
||||
|
||||
## Bounds
|
||||
|
||||
The media dimensions come from the sender, so the descent is bounded rather than trusted. Its
|
||||
first step caps both sides at 512 px and the resampled image at 400 px, so no image, whatever its
|
||||
shape, produces a large intermediate. That first step samples rather than averages - reading every
|
||||
pixel of a 4K video frame would stall composition - and the halving that follows averages away
|
||||
most of what it aliases. The ascent stops as soon as the longest side reaches 200 px, so it never
|
||||
makes an image with a side of 400 px or more; a 4:3 preview ends at 264x192 px, 203 KB.
|
||||
|
||||
The arithmetic was fuzzed over 4840 dimension and radius combinations: no crash, no
|
||||
non-termination, every dimension at least 1, no side over 400 px after the first step, largest
|
||||
single intermediate 1.00 MB (the 512 px first step), largest total churn 1.68 MB. A zero radius
|
||||
cannot reach the function; a negative one - possible from imported settings - yields a 1x1 image
|
||||
doubled to a flat 256x256, so it fails toward more blur rather than less.
|
||||
|
||||
## Verification
|
||||
|
||||
Built as a desktop AppImage from this branch and run against a profile with images and videos.
|
||||
Images and video previews render as a smooth blur with no visible blockiness, and the play button
|
||||
stays suppressed until the media is revealed. The shipped class was checked to contain the new path
|
||||
and no remaining reference to `BlurKt` or `BlurredEdgeTreatment`.
|
||||
|
||||
The doubling was chosen with a Java2D harness that runs the same arithmetic through the same
|
||||
bilinear `drawImage` the desktop `scale` uses, on a 300, 100 and 48 px preview at each radius,
|
||||
next to the Gaussian `Modifier.blur` drew (sigma 0.577r + 0.5, as `BlurEffect` converts it). Without
|
||||
the ascent the result shows the resample grid at every radius; with it the grid is gone at 1x and
|
||||
3x density, and RMSE against the Gaussian falls (300 px, Soft: 5.8 to 5.0; 48 px, Soft: 5.3 to
|
||||
3.5). Doubling past 200 px changes neither the picture nor the figure.
|
||||
|
||||
No before/after CPU figure is included. The only machine available had no GPU, so Skiko fell back
|
||||
to software rasterisation; a comparison measured there would overstate the gain, because on real
|
||||
hardware the old blur was largely GPU work. The claim this change rests on is structural - a
|
||||
constant recomputed every frame is now computed once - not measured.
|
||||
|
||||
The Android 8.0 to 11 no-op is read from the bytecode of the resolved artifact and has not been
|
||||
confirmed on a device of that vintage.
|
||||
|
||||
## Out of scope
|
||||
|
||||
iOS is unchanged. It has the same per-frame `.blur(radius:)` in `PrivacyBlur`, and `getLoadedImage`
|
||||
there does no downsampling at all, so the same change applies - but as its own diff.
|
||||
|
||||
`ChatInfoImage.kt` still blurs blocked members' avatars with `Modifier.blur`, so that one remains a
|
||||
no-op below API 31. It is a different feature and a different call site.
|
||||
|
||||
`CIVideoView` still extracts a full-resolution frame through the video player while the preview is
|
||||
blurred, the way `CIImageView` used to. The same treatment applies; the player's lifecycle makes it
|
||||
a larger change than this one.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Plan: make the desktop bars blur lightweight
|
||||
|
||||
## Context
|
||||
|
||||
Settings > Appearance > Blur blurs the app bars: `blurredBackgroundModifier` puts a `BlurEffect` on the bar's layer
|
||||
and draws a copy of the scrolled content inside it, so the blur is recomputed every frame. On desktop this made
|
||||
scrolling visibly laggy at the default radius of 50.
|
||||
|
||||
## Measurements
|
||||
|
||||
Measured on the software renderer (`SKIKO_RENDER_API=SOFTWARE`), 1376x768 window, density 1, scrolling a chat of 70
|
||||
messages. Frames were counted by capturing the window repeatedly and counting distinct images.
|
||||
|
||||
| | blur off | blur 50 |
|
||||
|---|---|---|
|
||||
| visible frames per second while scrolling | 14.5 | 5.2 - 8.7 |
|
||||
|
||||
A standalone harness that replays the same drawing through Skiko's `RenderNode` puts the cost of one bar at 29 ms
|
||||
(radius 30), 42 ms (radius 50) and 71 ms (radius 100). Two bars are drawn in a chat, so at the default radius the
|
||||
bars alone cost about 80 ms per frame.
|
||||
|
||||
## Cause
|
||||
|
||||
Skia's raster blur runs at full resolution: `Raster8888BlurAlgorithm` only rescales its input above sigma 135
|
||||
(`SkBlurEngine.cpp`), and every radius the slider offers is below that. Each bar therefore blurs
|
||||
`width x (bar + 6 sigma)` pixels per frame, which at radius 100 is the full window width by 404 rows.
|
||||
|
||||
The obvious remedy, blurring a downscaled copy the way Skia's own GPU path does, cannot be expressed by scaling the
|
||||
layer: **a layer's image filter is evaluated in device space**, so any scale on the layer or the canvas is applied to
|
||||
the sigma as well. Narrowing a bar eightfold and blurring it with an eighth of the sigma measured 68.5 ms against
|
||||
68.4 ms for the untouched blur, and looked wrong, because the reduced sigma ended up applied at full resolution.
|
||||
This is why the blur appeared weak enough to leave text under the bar readable.
|
||||
|
||||
## Fix
|
||||
|
||||
Draw the bar into an offscreen Skia surface that is up to 8x narrower, blur it there, where the surface's own
|
||||
resolution is the device resolution and the sigma is not scaled back up, then stretch the finished blur across the
|
||||
bar. Only the width is reduced: a bar is `AppBarHeight` tall, and taking rows away from it visibly weakens the blur,
|
||||
which the harness confirmed and which was visible in the app.
|
||||
|
||||
`drawBarsBlurred` is an `expect`/`actual` because it needs Skia surfaces directly. Android is untouched: it blurs
|
||||
through a `RenderEffect` on the layer, where the GPU blur rescales internally, so it never had this cost.
|
||||
|
||||
Blurring a copy has one consequence for when the bars draw. Previously the bar recorded `drawLayer(graphicsLayer)`, a
|
||||
live reference that replays whatever the content layer holds at playback, so the bar did not have to be redrawn when
|
||||
the content scrolled. Reading the content into a surface fixes it at the moment the bar draws instead, so the bar has
|
||||
to be redrawn whenever that copy is re-recorded, which `AppBarHandler.contentVersion` provides: `copyViewToAppBar`
|
||||
bumps it after each recording and the bar reads it. Without it the blur inside a chat lagged behind the content,
|
||||
while the chat list looked correct because its bar happened to be invalidated by other state.
|
||||
|
||||
The surfaces are kept per size rather than one pair overall, because the desktop window's panes have different
|
||||
widths and their bars are drawn in the same frame, which would otherwise reallocate both surfaces for every bar.
|
||||
|
||||
How far the copy is reduced follows from the blur itself. The width is divided until the blur's own sigma would fall
|
||||
below 1.5, past which the narrowed copy is barely blurred and stretching it back out shows the steps of the narrowing
|
||||
rather than a blur. The rows are halved once, with the vertical sigma halved to match. The rule has to be written in
|
||||
terms of the reduced sigma rather than the radius: stopping as soon as the reduced sigma reached 4, as a first version
|
||||
did, left the small radiuses barely narrowed at all and made radius 10 cost more than radius 50.
|
||||
|
||||
The blur is the surface's own layer paint rather than a copy between two surfaces, which is both one allocation fewer
|
||||
and, measured with the surfaces reused as the app reuses them, 3-11% quicker.
|
||||
|
||||
Per bar, against the unmodified blur, with surfaces reused:
|
||||
|
||||
| radius | before | after | difference from the original |
|
||||
|---|---|---|---|
|
||||
| 10 | 17.9 ms | 2.7 ms | 0.41 / 255 |
|
||||
| 50 | 40.9 ms | 3.0 ms | 0.23 / 255 |
|
||||
| 100 | 72.7 ms | 5.0 ms | 0.27 / 255 |
|
||||
|
||||
The difference column is the root mean square difference over the bar, before the bar's own tint is applied, so what
|
||||
reaches the screen is smaller still. Redrawing the bar and stretching the blur back over it cost 0.5 ms of those
|
||||
figures whatever the radius, so that is the floor this approach can reach.
|
||||
|
||||
## Keeping the bars in step with the content
|
||||
|
||||
A layer's own filter follows the content it is attached to for free. Blurring a copy does not: the copy is taken when
|
||||
the bar draws, so the bar has to be redrawn whenever the content it copies has moved. `AppBarHandler.contentVersion`
|
||||
carries that, and the scroll containers bump it from a collector on the state they actually scroll.
|
||||
|
||||
Two details are load-bearing. The bump must not happen while drawing: an earlier version bumped it inside
|
||||
`copyViewToAppBar`, and because Compose Desktop redraws the whole scene per frame, the content's draw invalidated the
|
||||
bar, which requested another frame, which drew the content again. Measured idle, with no interaction: **96% of a core**
|
||||
against 0% with the blur off and 0% for the unmodified app. And the version has to come from the state the container
|
||||
actually scrolls, not from the handler's own: a chat supplies its own `LazyListState`, so watching the handler's left
|
||||
the chat's blur stale while the chat list looked correct.
|
||||
|
||||
## Rejected
|
||||
|
||||
Reusing the blurred copy while the content is unchanged was measured and dropped: it saves nothing while scrolling,
|
||||
which is the case that lags, and the chat wallpaper is recorded into its own layer without touching the content
|
||||
version, so a cached blur could outlast a wallpaper change.
|
||||
|
||||
Blurring the bars of both panes in one pass does not apply: only `DefaultAppBar` is blurred, one per screen, and the
|
||||
two on a desktop window belong to panes of different widths side by side rather than stacked.
|
||||
|
||||
## Verification
|
||||
|
||||
Built as an AppImage and checked by eye across the whole slider range at bar alpha 0.5, where the blur is most
|
||||
exposed, on top of the chat list and inside a chat.
|
||||
@@ -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`.
|
||||
@@ -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 = ?
|
||||
@@ -1136,7 +1136,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
|
||||
@@ -1148,7 +1148,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
|
||||
@@ -1160,7 +1160,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
|
||||
|
||||