android, desktop: blur media thumbnails in reply quotes and the chat list (#7639)

"Blur media" hid images and videos in a chat but showed the same media
sharp as the thumbnail of a quoted message and as the last message's
image, video or link preview in the chat list.

Small views are now blurred with the same reveal as chat media. They are
resampled relative to the width they are drawn at, so a thumbnail is
blurred as much on screen as media in a chat, and drawn as the centre
crop the thumbnail shows. In-chat media keeps the same blur.

Blurring the chat list also needs: each last message gets its own blur
state, loaded chat-list videos use it and hide their full-screen play
button while blurred, a right-click on a thumbnail no longer leaves it
revealed on desktop, and leaving a chat mid-scroll no longer undoes
reveals in the list on Android.

Co-authored-by: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-10-03 07:17:20 +01:00
committed by GitHub
co-authored by Narasimha-sc
parent 205d17e2fa
commit 53e6d435bc
8 changed files with 174 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) {
@@ -2463,6 +2463,7 @@ fun BoxScope.ChatItemsList(
LaunchedEffect(Unit) {
snapshotFlow { listState.value.isScrollInProgress }
.onCompletion { chatViewScrollState.value = false }
.collect {
chatViewScrollState.value = it
}
@@ -117,7 +117,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.
@@ -149,11 +149,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),
@@ -188,7 +184,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) }
@@ -214,7 +210,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, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) })
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, @Composable { painter, onClick -> ImageView(painter, image, onClick) })
} else {
imageView(previewBitmap, onClick = {
if (file != null) {
@@ -55,7 +55,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)) }
@@ -85,11 +85,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)
}
@@ -185,16 +185,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 {
@@ -217,6 +218,7 @@ private fun SmallVideoView(
defaultPreview: ImageBitmap,
defaultDuration: Long,
autoPlay: MutableState<Boolean>,
blurred: MutableState<Boolean>,
sizeMultiplier: Float,
openFullscreen: () -> Unit
) {
@@ -234,8 +236,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)
}
}
@@ -135,6 +135,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(
@@ -152,7 +153,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 -> {
@@ -164,7 +165,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, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
CIImageView(image = mc.image, file = ci.file, provider, noMenu, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
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, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, noMenu, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
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.