Merge branch 'stable'

This commit is contained in:
Evgeny Poberezkin
2026-10-03 08:05:19 +01:00
9 changed files with 191 additions and 33 deletions
@@ -3,9 +3,12 @@ package chat.simplex.common.platform
import androidx.compose.foundation.combinedClickable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.KeyChangeEffect
@@ -29,14 +32,14 @@ expect fun Modifier.desktopPointerHoverIconHand(): Modifier
expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier
@Composable
fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
fun Modifier.desktopModifyBlurredState(blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
if (appPlatform.isDesktop) {
KeyChangeEffect(blurRadius.value) {
blurred.value = enabled && blurRadius.value > 0
blurred.value = blurRadius.value > 0
}
}
return if (appPlatform.isDesktop && enabled && blurRadius.value > 0 && !showMenu.value) {
return if (appPlatform.isDesktop && blurRadius.value > 0 && !showMenu.value) {
var job: Job = remember { Job() }
LaunchedEffect(Unit) {
// The approach here is to allow menu to show up and to not blur the view. When menu is shown and mouse is hovering,
@@ -64,24 +67,31 @@ fun blurHidesMedia(enabled: Boolean, blurred: State<Boolean>): Boolean =
@Composable
fun Modifier.privacyBlur(
enabled: Boolean,
fullSize: 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 (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())) }
return if (blurHidesMedia(true, blurred)) {
this then (if (fullSize) {
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
Modifier.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
} else Modifier.drawWithCache {
val cropScale = maxOf(size.width / preview.width, size.height / preview.height)
val croppedSize = IntSize((preview.width * cropScale).roundToInt(), (preview.height * cropScale).roundToInt())
val blurredCropped = preview.blurredBy(blurRadius.value, croppedSize.width.toDp().value)
val offset = IntOffset(((size.width - croppedSize.width) / 2).roundToInt(), ((size.height - croppedSize.height) / 2).roundToInt())
onDrawWithContent { clipRect { drawImage(blurredCropped, dstOffset = offset, dstSize = croppedSize) } }
})
.combinedClickable(
onLongClick = onLongClick,
onClick = {
blurred.value = false
}
)
} else if (enabled && blurRadius.value > 0 && appPlatform.isAndroid) {
} else if (blurRadius.value > 0 && appPlatform.isAndroid) {
LaunchedEffect(Unit) {
snapshotFlow { scrollState.value }
.filter { it }
@@ -96,12 +106,14 @@ fun Modifier.privacyBlur(
// 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
// The width in-chat media is assumed to be drawn at; small views are blurred as much on screen.
private const val CHAT_MEDIA_WIDTH_DP = 360
// 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 {
private fun ImageBitmap.blurredBy(radius: Int, drawnWidthDp: Float = CHAT_MEDIA_WIDTH_DP.toFloat()): ImageBitmap {
if (width <= 0 || height <= 0) return this
val w = (BLURRED_MEDIA_WIDTH_DP / radius).coerceIn(1, width)
val w = (BLURRED_MEDIA_WIDTH_DP * drawnWidthDp / CHAT_MEDIA_WIDTH_DP / radius).toInt().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) {
@@ -2470,6 +2470,7 @@ fun BoxScope.ChatItemsList(
LaunchedEffect(Unit) {
snapshotFlow { listState.value.isScrollInProgress }
.onCompletion { chatViewScrollState.value = false }
.collect {
chatViewScrollState.value = it
}
@@ -120,7 +120,7 @@ fun CIImageView(
}
@Composable
fun ImageView(painter: Painter, image: String, fileSource: CryptoFile?, onClick: () -> Unit) {
fun ImageView(painter: Painter, image: String, onClick: () -> Unit) {
// On my Android device Compose fails to display 6000x6000 px WebP image with exception:
// IllegalStateException: Recording currently in progress - missing #endRecording() call?
// but can display 5000px image. Using even lower value here just to feel safer.
@@ -152,11 +152,7 @@ fun CIImageView(
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentAlignment = Alignment.Center
) {
imageView(previewBitmap, onClick = {
if (fileSource != null) {
openFile(fileSource)
}
})
imageView(previewBitmap, onClick = onClick)
Icon(
painterResource(MR.images.ic_open_in_new),
contentDescription = stringResource(MR.strings.image_descr),
@@ -191,7 +187,7 @@ fun CIImageView(
}
} else Modifier
)
.desktopModifyBlurredState(!smallView, blurred, showMenu),
.desktopModifyBlurredState(blurred, showMenu),
contentAlignment = Alignment.TopEnd
) {
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
@@ -217,7 +213,7 @@ fun CIImageView(
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) })
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, onClick) })
} else {
imageView(previewBitmap, onClick = {
if (file != null) {
@@ -54,7 +54,7 @@ fun CIVideoView(
}
} else Modifier
)
.desktopModifyBlurredState(!smallView, blurred, showMenu),
.desktopModifyBlurredState(blurred, showMenu),
contentAlignment = Alignment.TopEnd
) {
val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) }
@@ -84,11 +84,11 @@ fun CIVideoView(
val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) }
val decrypted = uriDecrypted.value
if (decrypted != null && smallView) {
SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, sizeMultiplier, openFullscreen = openFullscreen)
SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, blurred, sizeMultiplier, openFullscreen = openFullscreen)
} else if (decrypted != null) {
VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
} else if (smallView) {
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, sizeMultiplier, openFullscreen = openFullscreen)
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, blurred, sizeMultiplier, openFullscreen = openFullscreen)
} else {
VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
}
@@ -184,16 +184,17 @@ private fun SmallVideoViewEncrypted(
defaultPreview: ImageBitmap,
autoPlay: MutableState<Boolean>,
showMenu: MutableState<Boolean>,
blurred: MutableState<Boolean>,
sizeMultiplier: Float,
openFullscreen: () -> Unit,
) {
var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) }
val onLongClick = { showMenu.value = true }
Box {
VideoPreviewImageView(defaultPreview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick)
VideoPreviewImageView(defaultPreview, smallView = true, blurred = blurred, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick)
if (decryptionInProgress) {
VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick)
} else if (!file.showStatusIconInSmallView) {
} else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
PlayButton(false, sizeMultiplier, onLongClick = onLongClick) {
decryptionInProgress = true
withBGApi {
@@ -216,6 +217,7 @@ private fun SmallVideoView(
defaultPreview: ImageBitmap,
defaultDuration: Long,
autoPlay: MutableState<Boolean>,
blurred: MutableState<Boolean>,
sizeMultiplier: Float,
openFullscreen: () -> Unit
) {
@@ -233,8 +235,8 @@ private fun SmallVideoView(
onLongClick = {},
{}
)
VideoPreviewImageView(preview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = openFullscreen, onLongClick = {})
if (!file.showStatusIconInSmallView) {
VideoPreviewImageView(preview, smallView = true, blurred = blurred, onClick = openFullscreen, onLongClick = {})
if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen)
}
}
@@ -153,6 +153,7 @@ fun FramedItemView(
@Composable
fun ciQuoteView(qi: CIQuote) {
val blurred = remember { mutableStateOf(appPreferences.privacyMediaBlurRadius.get() > 0) }
val sentColor = MaterialTheme.appColors.sentQuote
val receivedColor = MaterialTheme.appColors.receivedQuote
Row(
@@ -170,7 +171,7 @@ fun FramedItemView(
imageBitmap,
contentDescription = stringResource(MR.strings.image_descr),
contentScale = ContentScale.Crop,
modifier = Modifier.size(68.dp).clipToBounds()
modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true })
)
}
is MsgContent.MCVideo -> {
@@ -182,7 +183,7 @@ fun FramedItemView(
imageBitmap,
contentDescription = stringResource(MR.strings.video_descr),
contentScale = ContentScale.Crop,
modifier = Modifier.size(68.dp).clipToBounds()
modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true })
)
}
is MsgContent.MCFile, is MsgContent.MCVoice -> {
@@ -311,26 +311,31 @@ fun ChatPreviewView(
mutableStateOf({ providerForGallery(chat.chatItems, ci?.id ?: 0) {} })
}
val uriHandler = LocalUriHandler.current
// Media in the chat list has no menu, so a menu opened from it must close at once, or the media stays revealed.
val noMenu = remember { mutableStateOf(false) }
LaunchedEffect(noMenu.value) { noMenu.value = false }
when (mc) {
is MsgContent.MCLink -> SmallContentPreview {
val image = remember(mc.preview.image) { base64ToBitmap(mc.preview.image) }
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
IconButton(
{ openBrowserAlert(mc.preview.uri, uriHandler) },
Modifier.desktopPointerHoverIconHand(),
) {
Image(base64ToBitmap(mc.preview.image), null, contentScale = ContentScale.Crop)
Image(image, null, Modifier.desktopModifyBlurredState(blurred, noMenu).privacyBlur(fullSize = false, image, blurred, chatViewScrollState.collectAsState()), contentScale = ContentScale.Crop)
}
Box(Modifier.align(Alignment.TopEnd).size(15.sp.toDp()).background(Color.Black.copy(0.25f), CircleShape), contentAlignment = Alignment.Center) {
Icon(painterResource(MR.images.ic_arrow_outward), null, Modifier.size(13.sp.toDp()), tint = Color.White)
}
}
is MsgContent.MCImage -> SmallContentPreview {
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
CIImageView(image = mc.image, file = ci.file, provider, noMenu, smallView = true) {
val user = chatModel.currentUser.value ?: return@CIImageView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
}
is MsgContent.MCVideo -> SmallContentPreview {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, noMenu, smallView = true) {
val user = chatModel.currentUser.value ?: return@CIVideoView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
@@ -421,7 +426,7 @@ fun ChatPreviewView(
val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null
val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null
if (ci != null && showContentPreview) {
chatItemContentPreview(chat, ci)
key(ci.id) { chatItemContentPreview(chat, ci) }
}
if (mc !is MsgContent.MCVoice || !showContentPreview || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) {
Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) {
@@ -206,7 +206,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongC
stringResource(MR.strings.image_descr_link_preview),
modifier = Modifier
.fillMaxWidth()
.desktopModifyBlurredState(true, blurred, showMenu)
.desktopModifyBlurredState(blurred, showMenu)
.privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
contentScale = ContentScale.FillWidth,
)
+124
View File
@@ -0,0 +1,124 @@
# Blur media thumbnails in reply quotes and the chat list
## Problem
"Blur media" hides images and videos in a chat until they are tapped (on desktop, hovered), but
the same media is shown sharp in two other places:
- the thumbnail of a quoted image or video in a reply (`FramedItemView.ciQuoteView`, 68 dp), in
every chat, search result, report and support chat;
- the chat list, where the last message's image, video or link preview is drawn as a 36 sp
thumbnail next to the message text (`ChatPreviewView.chatItemContentPreview`).
The chat list is on screen most of the time, so it shows exactly what the setting promised to
hide.
## Cause
Small views were excluded on purpose: every call passed `privacyBlur(enabled = !smallView, ...)`
and `desktopModifyBlurredState(!smallView, ...)`, the quote thumbnail never called them, and the
chat-list link preview was a plain `Image`. The chat-list video views (`SmallVideoView`,
`SmallVideoViewEncrypted`) created their own `remember { mutableStateOf(false) }` blur state, so
they could not be blurred even if the modifier were enabled.
Enabling the existing blur for small views is not enough on its own, because
`ImageBitmap.blurredBy` (#7483) resamples the preview to `400 / radius` pixels across the whole
image, a width calibrated for media drawn about 360 dp wide. Drawn into a 36 sp thumbnail, 33 px
at Soft is almost sharp.
## Fix
`privacyBlur` takes `fullSize` in place of `enabled`. Existing call sites keep passing
`!smallView` or `true`, so in-chat media takes the same code as before: the same `remember`ed
resample, drawn with `drawWithContent`. Small views (`fullSize = false`):
- are resampled relative to their drawn width: `blurredBy` gets the drawn width, and the pixel
count is `400 * drawnWidth / 360 / radius`, so a thumbnail is blurred as much on screen as
media in a chat. The default width is 360, which gives exactly master's `400 / radius` for
in-chat media;
- are drawn as the centre crop of the image scaled by `ContentScale.Crop`, clipped to the view,
so the blurred thumbnail covers the same region as the revealed one and is blurred evenly in
both directions; a whole image squeezed into a square would be blurred up to 4x less along
its long side;
- use `drawWithCache`, so the blur is computed once per size and not on every frame.
`enabled` is removed from `desktopModifyBlurredState`: once small views are blurred every caller
passes `true`. It stays on `blurHidesMedia`, which still has a real caller (`CIImageView`).
Wiring:
- quote thumbnails get one blur state per quote, the item's menu state, and the same reveal as
chat media: a tap reveals, a second tap scrolls to the quoted message, long-press opens the
item menu;
- the chat-list link preview gets a remembered bitmap and blur state;
- `SmallVideoView` and `SmallVideoViewEncrypted` take the item's real blur state, and their play
buttons, which open the video full screen, are hidden while it is blurred.
## Leaks closed by the change
Blurring the chat list exposed three ways for a thumbnail to be shown, or stay shown, without
the user revealing it:
- the chat list is keyed by chat, not by message, so state remembered for one last message was
reused for the next: a new image arriving in a chat whose previous image was revealed would be
shown revealed. `key(ci.id)` around `chatItemContentPreview` gives each message its own state;
- the chat list passed CIImageView and CIVideoView a throwaway `showMenu` that their long-press
and right-click set to `true` and nothing reset. On desktop `desktopModifyBlurredState` stops
re-blurring on hover-out while `showMenu` is true, so a right-clicked thumbnail stayed sharp.
The chat list now passes `noMenu`, which a `LaunchedEffect` resets as soon as it is set: a
menu that closes at once;
- `chatViewScrollState` is a global written only by the chat view's list, and its collector was
cancelled without a final value when a chat closed. Leaving a chat while its list was still
flinging left it `true`, and every reveal in the chat list was undone at once by the Android
re-blur, until another chat was opened. The collector now writes `false` on completion.
## Bounds
The preview dimensions come from the sender. `base64ToBitmap` never returns a bitmap with a zero
side (it falls back to an error bitmap), so the crop scale is finite. A zero-size view gives a
zero crop, which `blurredBy` clamps to one pixel. `blurredBy` keeps its existing bounds: the
first step caps both sides at 512 px and the output height at 400 px; a very wide preview widens
the resample to at most its own width while its height drops to a few pixels. The doubling
ascent still targets 200 px, so a thumbnail holds a bitmap of up to 384 px (about 590 KB) - more
than it needs at 36 sp, harmless.
## Base
The change is built on `stable` after #7483 (blur media by resampling), because the small-view
blur extends #7483's resample. On stable, `SimpleAndAnimatedImageView` has no `blurred`
parameter, which came from #7365 (animated images) on master; neither #7483 nor this change
depends on it.
## Verification
Read-only so far; nothing on this branch has been built or run.
- In-chat media: `(400f * 360 / 360 / r).toInt()` equals master's `400 / r` for every positive
radius, and the `fullSize` path is master's code re-indented.
- The centre crop was checked by simulating Compose's `ContentScale.Crop` / `Alignment.Center`
placement against the blur's crop for 125,388 combinations of preview size (1 to 10000 px,
extreme aspects), view size (0 to 600 px) and measurement mode: no pixel differed.
- `Modifier.kt` was compiled with Kotlin 2.1.20 and the Compose 1.8.2 plugin against the 1.8.2
desktop jars, app references stubbed; the bytecode shows the `drawWithCache` lambda is
remembered on the preview and radius, so recomposition does not re-blur.
- Independent adversarial reviews, on master and again on the stable base, ended with two
consecutive passes finding no defect.
To do before merging: build desktop and Android, and check with a test profile receiving an
image, a video and a link as a chat's last message, and a reply quoting each, at Soft, Medium and
Strong.
## Out of scope
- Keyboard and TalkBack: the outer click of a blurred video opens it full screen without
revealing it first. This already happens in a chat on stable and master; fixing it changes
in-chat behaviour, so it is a separate change. TalkBack also cannot reveal a blurred chat-list
image.
- Turning blur on does not re-blur media already on screen on Android (desktop does); chat-list
rows pick it up when recomposed from scratch.
- Chat-list reveals re-blur when a chat is scrolled, not when the chat list is.
- At 36 sp, Medium and Strong both resample to a single pixel: a flat colour.
- Chat-link cards (contact and group profile images), compose-bar previews of your own media and
links, and the full-screen gallery (which shows neighbouring media sharp when swiped) are
unchanged.
- iOS has the same gaps in quotes and the chat list; it follows as a separate change.
@@ -38,6 +38,23 @@
</description>
<releases>
<release version="7.0.3" date="2026-09-26">
<url type="details">https://simplex.chat/blog/20260722-simplex-public-names.html</url>
<description>
<p>New in v7.0-v7.0.3.</p>
<p>SimpleX public names for channels and businesses (BETA).</p>
<p>Better channels:</p>
<ul>
<li>Promote subscribers to contributors.</li>
<li>Publish your channel on your website.</li>
<li>Verify security code with contributors.</li>
<li>Wider messages, easier to read.</li>
<li>Host your own chat relays.</li>
</ul>
<p>Add a longer description to your profile.</p>
<p>Simplified app settings.</p>
</description>
</release>
<release version="7.0.2" date="2026-09-02">
<url type="details">https://simplex.chat/blog/20260722-simplex-public-names.html</url>
<description>