Merge branch 'stable' into stable-android

This commit is contained in:
Simplex Operations committed 2026-10-03 09:26:38 +00:00
commit 00bb71ef10
15 files changed
+395 -82

No files matched your search

+20 -20
View File
@@ -2085,7 +2085,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 359; CURRENT_PROJECT_VERSION = 361;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -2110,7 +2110,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
LLVM_LTO = YES_THIN; LLVM_LTO = YES_THIN;
MARKETING_VERSION = 7.0.3; MARKETING_VERSION = 7.0.4;
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
@@ -2135,7 +2135,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 359; CURRENT_PROJECT_VERSION = 361;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -2160,7 +2160,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
LLVM_LTO = YES; LLVM_LTO = YES;
MARKETING_VERSION = 7.0.3; MARKETING_VERSION = 7.0.4;
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
@@ -2177,11 +2177,11 @@
buildSettings = { buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 359; CURRENT_PROJECT_VERSION = 361;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 7.0.3; MARKETING_VERSION = 7.0.4;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -2197,11 +2197,11 @@
buildSettings = { buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 359; CURRENT_PROJECT_VERSION = 361;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 7.0.3; MARKETING_VERSION = 7.0.4;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -2222,7 +2222,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 359; CURRENT_PROJECT_VERSION = 361;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s; GCC_OPTIMIZATION_LEVEL = s;
@@ -2237,7 +2237,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LLVM_LTO = YES; LLVM_LTO = YES;
MARKETING_VERSION = 7.0.3; MARKETING_VERSION = 7.0.4;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2259,7 +2259,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 359; CURRENT_PROJECT_VERSION = 361;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO; ENABLE_CODE_COVERAGE = NO;
@@ -2274,7 +2274,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LLVM_LTO = YES; LLVM_LTO = YES;
MARKETING_VERSION = 7.0.3; MARKETING_VERSION = 7.0.4;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2296,7 +2296,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 359; CURRENT_PROJECT_VERSION = 361;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2322,7 +2322,7 @@
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
LLVM_LTO = YES; LLVM_LTO = YES;
MARKETING_VERSION = 7.0.3; MARKETING_VERSION = 7.0.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -2347,7 +2347,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 359; CURRENT_PROJECT_VERSION = 361;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2374,7 +2374,7 @@
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
LLVM_LTO = YES; LLVM_LTO = YES;
MARKETING_VERSION = 7.0.3; MARKETING_VERSION = 7.0.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -2401,7 +2401,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 359; CURRENT_PROJECT_VERSION = 361;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2416,7 +2416,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 7.0.3; MARKETING_VERSION = 7.0.4;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -2435,7 +2435,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 359; CURRENT_PROJECT_VERSION = 361;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2450,7 +2450,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 7.0.3; MARKETING_VERSION = 7.0.4;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1,18 +1,21 @@
package chat.simplex.common.platform package chat.simplex.common.platform
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.blur 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.graphics.painter.Painter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.KeyChangeEffect import chat.simplex.common.views.helpers.KeyChangeEffect
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import java.io.File import java.io.File
import kotlin.math.roundToInt
@Composable @Composable
expect fun Modifier.desktopOnExternalDrag( expect fun Modifier.desktopOnExternalDrag(
@@ -29,14 +32,14 @@ expect fun Modifier.desktopPointerHoverIconHand(): Modifier
expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier
@Composable @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 } val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
if (appPlatform.isDesktop) { if (appPlatform.isDesktop) {
KeyChangeEffect(blurRadius.value) { 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() } var job: Job = remember { Job() }
LaunchedEffect(Unit) { 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, // The approach here is to allow menu to show up and to not blur the view. When menu is shown and mouse is hovering,
@@ -58,27 +61,37 @@ 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 @Composable
fun Modifier.privacyBlur( fun Modifier.privacyBlur(
enabled: Boolean, fullSize: Boolean,
preview: ImageBitmap,
blurred: MutableState<Boolean> = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }, blurred: MutableState<Boolean> = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) },
scrollState: State<Boolean>, scrollState: State<Boolean>,
onLongClick: () -> Unit = {} onLongClick: () -> Unit = {}
): Modifier { ): Modifier {
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state } val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
return if (enabled && blurred.value) { return if (blurHidesMedia(true, blurred)) {
this then Modifier.blur( this then (if (fullSize) {
radiusX = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp, val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
radiusY = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp, Modifier.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
edgeTreatment = BlurredEdgeTreatment(RoundedCornerShape(0.dp)) } 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( .combinedClickable(
onLongClick = onLongClick, onLongClick = onLongClick,
onClick = { onClick = {
blurred.value = false blurred.value = false
} }
) )
} else if (enabled && blurRadius.value > 0 && appPlatform.isAndroid) { } else if (blurRadius.value > 0 && appPlatform.isAndroid) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { scrollState.value } snapshotFlow { scrollState.value }
.filter { it } .filter { it }
@@ -90,3 +103,27 @@ fun Modifier.privacyBlur(
this 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
// 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, drawnWidthDp: Float = CHAT_MEDIA_WIDTH_DP.toFloat()): ImageBitmap {
if (width <= 0 || height <= 0) return this
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) {
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
}
@@ -2463,6 +2463,7 @@ fun BoxScope.ChatItemsList(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { listState.value.isScrollInProgress } snapshotFlow { listState.value.isScrollInProgress }
.onCompletion { chatViewScrollState.value = false }
.collect { .collect {
chatViewScrollState.value = it chatViewScrollState.value = it
} }
@@ -111,13 +111,13 @@ fun CIImageView(
onClick = onClick onClick = onClick
) )
.onRightClick { showMenu.value = true } .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, contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
) )
} }
@Composable @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: // On my Android device Compose fails to display 6000x6000 px WebP image with exception:
// IllegalStateException: Recording currently in progress - missing #endRecording() call? // IllegalStateException: Recording currently in progress - missing #endRecording() call?
// but can display 5000px image. Using even lower value here just to feel safer. // but can display 5000px image. Using even lower value here just to feel safer.
@@ -135,7 +135,7 @@ fun CIImageView(
onClick = onClick onClick = onClick
) )
.onRightClick { showMenu.value = true } .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, contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
) )
} else { } else {
@@ -146,14 +146,10 @@ fun CIImageView(
onClick = {} onClick = {}
) )
.onRightClick { showMenu.value = true } .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 contentAlignment = Alignment.Center
) { ) {
imageView(previewBitmap, onClick = { imageView(previewBitmap, onClick = onClick)
if (fileSource != null) {
openFile(fileSource)
}
})
Icon( Icon(
painterResource(MR.images.ic_open_in_new), painterResource(MR.images.ic_open_in_new),
contentDescription = stringResource(MR.strings.image_descr), contentDescription = stringResource(MR.strings.image_descr),
@@ -188,29 +184,33 @@ fun CIImageView(
} }
} else Modifier } else Modifier
) )
.desktopModifyBlurredState(!smallView, blurred, showMenu), .desktopModifyBlurredState(blurred, showMenu),
contentAlignment = Alignment.TopEnd contentAlignment = Alignment.TopEnd
) { ) {
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) } val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
if (chatModel.connectedToRemote()) { // Hidden media is not worth reading, decoding at full size and caching.
LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) { val revealed = !blurHidesMedia(!smallView, blurred)
withBGApi { 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)) { 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) { if (loaded != null && file != null) {
val (imageBitmap, data, _) = loaded 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 { } else {
imageView(previewBitmap, onClick = { imageView(previewBitmap, onClick = {
if (file != null) { if (file != null) {
@@ -55,7 +55,7 @@ fun CIVideoView(
} }
} else Modifier } else Modifier
) )
.desktopModifyBlurredState(!smallView, blurred, showMenu), .desktopModifyBlurredState(blurred, showMenu),
contentAlignment = Alignment.TopEnd contentAlignment = Alignment.TopEnd
) { ) {
val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) } 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 uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) }
val decrypted = uriDecrypted.value val decrypted = uriDecrypted.value
if (decrypted != null && smallView) { 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) { } else if (decrypted != null) {
VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen) VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
} else if (smallView) { } else if (smallView) {
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, sizeMultiplier, openFullscreen = openFullscreen) SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, blurred, sizeMultiplier, openFullscreen = openFullscreen)
} else { } else {
VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen) VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
} }
@@ -185,16 +185,17 @@ private fun SmallVideoViewEncrypted(
defaultPreview: ImageBitmap, defaultPreview: ImageBitmap,
autoPlay: MutableState<Boolean>, autoPlay: MutableState<Boolean>,
showMenu: MutableState<Boolean>, showMenu: MutableState<Boolean>,
blurred: MutableState<Boolean>,
sizeMultiplier: Float, sizeMultiplier: Float,
openFullscreen: () -> Unit, openFullscreen: () -> Unit,
) { ) {
var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) }
val onLongClick = { showMenu.value = true } val onLongClick = { showMenu.value = true }
Box { 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) { if (decryptionInProgress) {
VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick) VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick)
} else if (!file.showStatusIconInSmallView) { } else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
PlayButton(false, sizeMultiplier, onLongClick = onLongClick) { PlayButton(false, sizeMultiplier, onLongClick = onLongClick) {
decryptionInProgress = true decryptionInProgress = true
withBGApi { withBGApi {
@@ -217,6 +218,7 @@ private fun SmallVideoView(
defaultPreview: ImageBitmap, defaultPreview: ImageBitmap,
defaultDuration: Long, defaultDuration: Long,
autoPlay: MutableState<Boolean>, autoPlay: MutableState<Boolean>,
blurred: MutableState<Boolean>,
sizeMultiplier: Float, sizeMultiplier: Float,
openFullscreen: () -> Unit openFullscreen: () -> Unit
) { ) {
@@ -234,8 +236,8 @@ private fun SmallVideoView(
onLongClick = {}, onLongClick = {},
{} {}
) )
VideoPreviewImageView(preview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = openFullscreen, onLongClick = {}) VideoPreviewImageView(preview, smallView = true, blurred = blurred, onClick = openFullscreen, onLongClick = {})
if (!file.showStatusIconInSmallView) { if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen) PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen)
} }
} }
@@ -422,7 +424,7 @@ fun VideoPreviewImageView(
onClick = onClick onClick = onClick
) )
.onRightClick(onLongClick) .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, contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
) )
} }
@@ -135,6 +135,7 @@ fun FramedItemView(
@Composable @Composable
fun ciQuoteView(qi: CIQuote) { fun ciQuoteView(qi: CIQuote) {
val blurred = remember { mutableStateOf(appPreferences.privacyMediaBlurRadius.get() > 0) }
val sentColor = MaterialTheme.appColors.sentQuote val sentColor = MaterialTheme.appColors.sentQuote
val receivedColor = MaterialTheme.appColors.receivedQuote val receivedColor = MaterialTheme.appColors.receivedQuote
Row( Row(
@@ -152,7 +153,7 @@ fun FramedItemView(
imageBitmap, imageBitmap,
contentDescription = stringResource(MR.strings.image_descr), contentDescription = stringResource(MR.strings.image_descr),
contentScale = ContentScale.Crop, 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 -> { is MsgContent.MCVideo -> {
@@ -164,7 +165,7 @@ fun FramedItemView(
imageBitmap, imageBitmap,
contentDescription = stringResource(MR.strings.video_descr), contentDescription = stringResource(MR.strings.video_descr),
contentScale = ContentScale.Crop, 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 -> { is MsgContent.MCFile, is MsgContent.MCVoice -> {
@@ -311,26 +311,31 @@ fun ChatPreviewView(
mutableStateOf({ providerForGallery(chat.chatItems, ci?.id ?: 0) {} }) mutableStateOf({ providerForGallery(chat.chatItems, ci?.id ?: 0) {} })
} }
val uriHandler = LocalUriHandler.current 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) { when (mc) {
is MsgContent.MCLink -> SmallContentPreview { is MsgContent.MCLink -> SmallContentPreview {
val image = remember(mc.preview.image) { base64ToBitmap(mc.preview.image) }
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
IconButton( IconButton(
{ openBrowserAlert(mc.preview.uri, uriHandler) }, { openBrowserAlert(mc.preview.uri, uriHandler) },
Modifier.desktopPointerHoverIconHand(), 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) { 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) Icon(painterResource(MR.images.ic_arrow_outward), null, Modifier.size(13.sp.toDp()), tint = Color.White)
} }
} }
is MsgContent.MCImage -> SmallContentPreview { 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 val user = chatModel.currentUser.value ?: return@CIImageView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
} }
} }
is MsgContent.MCVideo -> SmallContentPreview { 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 val user = chatModel.currentUser.value ?: return@CIVideoView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
} }
@@ -421,7 +426,7 @@ fun ChatPreviewView(
val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null
val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null
if (ci != null && showContentPreview) { 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) { 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)) { Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) {
@@ -194,7 +194,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel
@Composable @Composable
fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongClick: () -> Unit) { fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongClick: () -> Unit) {
val image = base64ToBitmap(linkPreview.image) val image = remember(linkPreview.image) { base64ToBitmap(linkPreview.image) }
Column( Column(
Modifier Modifier
.layoutId(CHAT_IMAGE_LAYOUT_ID) .layoutId(CHAT_IMAGE_LAYOUT_ID)
@@ -206,8 +206,8 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongC
stringResource(MR.strings.image_descr_link_preview), stringResource(MR.strings.image_descr_link_preview),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.desktopModifyBlurredState(true, blurred, showMenu) .desktopModifyBlurredState(blurred, showMenu)
.privacyBlur(true, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick), .privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
) )
Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) { Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) {
+4 -4
View File
@@ -24,11 +24,11 @@ android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11 kotlin.jvm.target=11
android.version_name=7.0.3 android.version_name=7.0.4
android.version_code=381 android.version_code=384
desktop.version_name=7.0.3 desktop.version_name=7.0.4
desktop.version_code=164 desktop.version_code=166
kotlin.version=2.1.20 kotlin.version=2.1.20
gradle.plugin.version=8.7.0 gradle.plugin.version=8.7.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "simplex-chat", "name": "simplex-chat",
"version": "7.0.3", "version": "7.0.4",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"files": [ "files": [
@@ -4,7 +4,7 @@ const path = require('path');
const extract = require('extract-zip'); const extract = require('extract-zip');
const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; const GITHUB_REPO = 'simplex-chat/simplex-chat-libs';
const RELEASE_TAG = 'v7.0.3'; const RELEASE_TAG = 'v7.0.4';
const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase();
if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') {
@@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440
post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged.
""" """
__version__ = "7.0.3" # PEP 440 — read by hatchling for wheel metadata __version__ = "7.0.4" # PEP 440 — read by hatchling for wheel metadata
LIBS_VERSION = "7.0.3" # simplex-chat-libs release tag (no 'v' prefix) LIBS_VERSION = "7.0.4" # simplex-chat-libs release tag (no 'v' prefix)
+126
View File
@@ -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.
+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> </description>
<releases> <releases>
<release version="7.0.4" date="2026-10-03">
<url type="details">https://simplex.chat/blog/20260722-simplex-public-names.html</url>
<description>
<p>New in v7.0-v7.0.4.</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.3" date="2026-09-26"> <release version="7.0.3" date="2026-09-26">
<url type="details">https://simplex.chat/blog/20260722-simplex-public-names.html</url> <url type="details">https://simplex.chat/blog/20260722-simplex-public-names.html</url>
<description> <description>