diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt index be7022ca80..5de9b7a4f4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt @@ -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): Boolean = + enabled && blurred.value && remember { appPrefs.privacyMediaBlurRadius.state }.value > 0 + @Composable fun Modifier.privacyBlur( enabled: Boolean, + preview: ImageBitmap, blurred: MutableState = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }, scrollState: State, 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 +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 61b6e450c5..5f5c207d08 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -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?> = 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) }) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 556b386900..ef600c8443 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -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, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt index d2a98ae101..0c6fec52f0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt @@ -194,7 +194,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel @Composable fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State, 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, 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)) { diff --git a/plans/2026-09-10-blur-media-resample.md b/plans/2026-09-10-blur-media-resample.md new file mode 100644 index 0000000000..0eb3cfe0ab --- /dev/null +++ b/plans/2026-09-10-blur-media-resample.md @@ -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.