diff --git a/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift index c9924223b5..0ee07ea828 100644 --- a/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift +++ b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift @@ -22,11 +22,11 @@ struct GetStakeBanner: View { Button(action: onTap) { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 4) { - Text("Get a stake in SimpleX Chat!") + Text("Invest in SimpleX Chat!") .font(.headline) .foregroundColor(theme.colors.primary) .lineLimit(2) - Text("Invest on Wefunder from $100") + Text("Crowdfunding on Wefunder") .font(.subheadline) .foregroundColor(theme.colors.onBackground) .lineLimit(2) diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 6cafb78043..79023a5821 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -890,7 +890,7 @@ struct GetStakeView: View { var body: some View { ZoomablePageView { VStack(alignment: .leading, spacing: 18) { - Text(verbatim: "Get a stake in\nSimpleX Chat") + Text(verbatim: "Invest in\nSimpleX Chat") .font(.largeTitle) .bold() .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index c1d1e5b111..d10c199e78 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -13,6 +13,8 @@ import SimpleXChat let simplexTeamURL = URL(string: "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im")! +let simplexNewsURL = URL(string: "simplex:/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA?h=smp18.simplex.im")! + let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String @@ -462,6 +464,15 @@ struct SettingsView: View { } Section(header: Text("Contact").foregroundColor(theme.colors.secondary)) { + settingsRow("antenna.radiowaves.left.and.right", color: theme.colors.secondary) { + Button("Follow SimpleX Network News") { + dismiss() + DispatchQueue.main.async { + ChatModel.shared.appOpenUrl = simplexNewsURL + } + } + } + .disabled(chatModel.chatRunning != true) settingsRow("number", color: theme.colors.secondary) { Button("Send questions and ideas") { dismiss() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/BarsBlur.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/BarsBlur.android.kt new file mode 100644 index 0000000000..bae80e11d0 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/BarsBlur.android.kt @@ -0,0 +1,6 @@ +package chat.simplex.common.platform + +import androidx.compose.ui.graphics.drawscope.DrawScope + +// Android blurs the bars with a RenderEffect on the layer itself, so this is never called there. +actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) = drawBar() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/BarsBlur.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/BarsBlur.kt new file mode 100644 index 0000000000..a7bd229d7f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/BarsBlur.kt @@ -0,0 +1,6 @@ +package chat.simplex.common.platform + +import androidx.compose.ui.graphics.drawscope.DrawScope + +// Draws the bars' background blurred. The drawing to blur is given in bar coordinates, whatever size the blur runs at. +expect fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) 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/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index fae1b7c155..5f296655b7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -779,7 +779,7 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) { } else { withBGApi { chatModel.appOpenUrlConnecting.value = true - planAndConnect(rhId, uri, close = null, cleanup = { chatModel.appOpenUrlConnecting.value = false }) + planAndConnect(rhId, uri, close = { ModalManager.closeAllModalsEverywhere() }, cleanup = { chatModel.appOpenUrlConnecting.value = false }) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt index c7553b6ed0..6b39c80058 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.unit.* import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.drawBarsBlurred import chat.simplex.common.ui.theme.CurrentColors fun Modifier.blurredBackgroundModifier( @@ -32,7 +33,7 @@ fun Modifier.blurredBackgroundModifier( return if (appPlatform.isAndroid) { this.androidBlurredModifier(keyboardInset, blurRadius.value, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density) } else { - this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density) + this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, handler, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density) } } @@ -100,40 +101,44 @@ private fun Modifier.desktopBlurredModifier( blurRadius: State, keyboardCoversBar: Boolean, onTop: Boolean, + handler: AppBarHandler, graphicsLayer: GraphicsLayer, backgroundGraphicsLayer: GraphicsLayer, backgroundGraphicsLayerSize: State, density: Density ): Modifier = this - .graphicsLayer { - renderEffect = if (blurRadius.value > 0) BlurEffect(blurRadius.value.dp.toPx(), blurRadius.value.dp.toPx()) else null - clip = blurRadius.value > 0 - } .drawBehind { - drawRect(CurrentColors.value.colors.background) - if (onTop) { - clipRect { - if (backgroundGraphicsLayer.size != IntSize.Zero) { - drawLayer(backgroundGraphicsLayer) - } else { - drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat())) + val barWidth = size.width + val barHeight = size.height + // The blur is taken from a copy of the scrolled content rather than drawn from that layer, so unlike a layer's own + // filter it does not follow the content on its own: the bar is redrawn when the container reports the copy moved. + handler.contentVersion.value + drawBarsBlurred(blurRadius.value.dp.toPx(), barWidth, barHeight) { + drawRect(CurrentColors.value.colors.background, size = Size(barWidth, barHeight)) + if (onTop) { + clipRect(0f, 0f, barWidth, barHeight) { + if (backgroundGraphicsLayer.size != IntSize.Zero) { + drawLayer(backgroundGraphicsLayer) + } else { + drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat())) + } + drawLayer(graphicsLayer) } - drawLayer(graphicsLayer) - } - } else { - val bgSize = when { - backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height - backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height - else -> backgroundGraphicsLayerSize.value.height - } - val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0 - translate(top = -bgSize + size.height + keyboardHeightCovered) { - if (backgroundGraphicsLayer.size != IntSize.Zero) { - drawLayer(backgroundGraphicsLayer) - } else { - drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat())) + } else { + val bgSize = when { + backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height + backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height + else -> backgroundGraphicsLayerSize.value.height + } + val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0 + translate(top = -bgSize + barHeight + keyboardHeightCovered) { + if (backgroundGraphicsLayer.size != IntSize.Zero) { + drawLayer(backgroundGraphicsLayer) + } else { + drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat())) + } + drawLayer(graphicsLayer) } - drawLayer(graphicsLayer) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt index ad6611b9d9..3e29649517 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt @@ -78,6 +78,10 @@ class AppBarHandler( val backgroundGraphicsLayerSize: MutableState = mutableStateOf(IntSize.Zero) + // Bars that blur a copy of the scrolled content depend on this to know the copy moved. It is bumped from a collector + // rather than while drawing: a write made during the draw phase invalidates the bars every frame and never settles. + val contentVersion: MutableState = mutableStateOf(0) + companion object { var appBarMaxHeightPx: Int = 0 } 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/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index 0fa53ea6de..39a6514302 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -1033,7 +1033,7 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean { return v != lastVersion } -private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat" +private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat?utm_source=app" private const val CROWDFUNDING_CONTACT_URI = "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im" @@ -1197,7 +1197,7 @@ fun GetStakeView(showFirstImage: Boolean, inCenterOfWindow: Boolean = false, clo ColumnWithScrollBar(Modifier.pinchZoom().padding(horizontal = DEFAULT_PADDING)) { // in the center of the window the page is wide enough for the title to fit on one line - val title = "Get a stake in\nSimpleX Chat" + val title = "Invest in\nSimpleX Chat" AppBarTitle(if (inCenterOfWindow) title.replace("\n", " ") else title, withPadding = false) // What's new already shows the image of the first slide, above the link that opens this page if (showFirstImage) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index c49caf7cfa..32f1b6384a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -74,6 +74,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: ( val simplexTeamUri = "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" +val simplexNewsUri = + "simplex:/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA?h=smp18.simplex.im" + @Composable fun SettingsLayout( stopped: Boolean, @@ -161,6 +164,7 @@ fun HelpAndSupportView( SectionView(stringResource(MR.strings.settings_section_title_contact)) { if (!chatModel.desktopNoUserNoRemote) { + SettingsActionItem(painterResource(MR.images.ic_bigtop_updates), stringResource(MR.strings.follow_simplex_network_news), { uriHandler.openVerifiedSimplexUri(simplexNewsUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) } SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 42aecc9cfe..bce235edec 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1015,6 +1015,7 @@ Markdown in messages Send questions and ideas Send us email + Follow SimpleX Network News SimpleX Lock Chat console Message servers @@ -2678,8 +2679,8 @@ Crowdfunding on Wefunder. Crowdfunding on Wefunder Learn more on Wefunder - Get a stake in SimpleX Chat! - Invest on Wefunder from $100 + Invest in SimpleX Chat! + Crowdfunding on Wefunder SimpleX public names (BETA) Public names for your channel or business. Better channels 📢 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/BarsBlur.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/BarsBlur.desktop.kt new file mode 100644 index 0000000000..6c4ee704bb --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/BarsBlur.desktop.kt @@ -0,0 +1,95 @@ +package chat.simplex.common.platform + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlurEffect +import androidx.compose.ui.graphics.asComposeCanvas +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.* +import kotlin.math.ceil + +// Skia's raster blur costs width x (bar + 6 sigma) pixels every frame, which is what makes the bars lag. The blur cannot be +// made cheaper by scaling the layer it runs on, because a layer's filter is evaluated in device space: any scale applied to +// the layer scales the sigma straight back up, which weakens the blur without saving anything. So the bar is drawn into a +// narrower offscreen surface and blurred there, at its own resolution, and only the finished blur is scaled back out. +// Only the width is reduced: a bar is just AppBarHeight tall, and taking rows away from it visibly weakens the blur. +private const val MAX_BLUR_NARROWING = 8 +private const val MIN_BLUR_WIDTH = 128 + +// How small the blur's own sigma is allowed to get. Below about this the narrowed copy is barely blurred, and stretching it +// back out shows the steps of the narrowing instead of a blur. +private const val MIN_NARROWED_SIGMA = 1.5f + +// The rows are halved as well, with the vertical sigma halved to match, which is only sound because the copy is drawn at +// that size rather than scaled afterwards. A bar has few rows to begin with, so it is not reduced past this. +private const val MIN_BLUR_HEIGHT = 24 + +// The panes of the desktop window have different widths, so bars of several sizes are drawn in the same frame and a single +// pair of surfaces would be reallocated for each of them. Keeping one pair per size, with the least recently drawn size +// released once there are more than the window can show at once, allocates each pair once instead. +private const val MAX_BAR_SURFACES = 4 + +private class BarBlurSurface(width: Int, height: Int) { + val surface: Surface = Surface.makeRaster(ImageInfo.makeN32Premul(width, height)) + + fun close() = surface.close() +} + +private val barBlurSurfaces = ThreadLocal.withInitial { LinkedHashMap() } + +private fun surfaceFor(width: Int, height: Int): BarBlurSurface { + val cached = barBlurSurfaces.get() + val key = (width.toLong() shl 32) or height.toLong() + val surface = cached.remove(key) ?: BarBlurSurface(width, height) + cached[key] = surface + while (cached.size > MAX_BAR_SURFACES) { + cached.remove(cached.keys.first())?.close() + } + return surface +} + +private fun narrowing(sigma: Float, barWidth: Float): Int { + var scale = 1 + while (sigma / (scale * 2) >= MIN_NARROWED_SIGMA && scale < MAX_BLUR_NARROWING && barWidth / (scale * 2) >= MIN_BLUR_WIDTH) scale *= 2 + return scale +} + +private fun shortening(sigma: Float, barHeight: Float): Int = + if (sigma / 2 >= MIN_NARROWED_SIGMA && barHeight / 2 >= MIN_BLUR_HEIGHT) 2 else 1 + +actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) { + val sigma = BlurEffect.convertRadiusToSigma(radiusPx) + val scale = narrowing(sigma, barWidth) + val rows = shortening(sigma, barHeight) + val width = ceil(barWidth / scale).toInt() + val height = ceil(barHeight / rows).toInt() + if (width <= 0 || height <= 0) return + val surface = surfaceFor(width, height).surface + + surface.canvas.clear(Color.TRANSPARENT) + // the blur is the layer's own paint, so it runs at this surface's resolution rather than at the canvas the bar draws to + surface.canvas.saveLayer(Rect.makeWH(width.toFloat(), height.toFloat()), Paint().apply { + imageFilter = ImageFilter.makeBlur(sigma / scale, sigma / rows, FilterTileMode.CLAMP) + }) + CanvasDrawScope().draw(Density(density, fontScale), layoutDirection, surface.canvas.asComposeCanvas(), Size(width.toFloat(), height.toFloat())) { + scale(1f / scale, 1f / rows, Offset.Zero) { drawBar() } + } + surface.canvas.restore() + + // The surface is whole pixels while the bar is not, so only the bar's own area is taken from it. + drawIntoCanvas { + it.nativeCanvas.drawImageRect( + surface.makeImageSnapshot(), + Rect.makeWH(barWidth / scale, barHeight / rows), + Rect.makeWH(barWidth, barHeight), + SamplingMode.LINEAR, + null, + true + ) + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt index 696e0efde8..2ebec123ad 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt @@ -64,6 +64,9 @@ actual fun LazyColumnWithScrollBar( } val state = state ?: handler.listState val connection = handler.connection + LaunchedEffect(state, handler) { + snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }.collect { handler.contentVersion.value++ } + } // When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on lazy column state // (only first visible row is useful because LazyColumn doesn't have absolute scroll position, only relative to row) val scrollBarDraggingState = remember { mutableStateOf(false) } @@ -202,6 +205,9 @@ actual fun ColumnWithScrollBar( } } val state = state ?: handler.scrollState + LaunchedEffect(state, handler) { + snapshotFlow { state.value }.collect { handler.contentVersion.value++ } + } val connection = handler.connection // When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on column state // (exact scroll position is available but in Int, not Float) diff --git a/apps/simplex-badge-service/README.md b/apps/simplex-badge-service/README.md index 945256f814..8467e57906 100644 --- a/apps/simplex-badge-service/README.md +++ b/apps/simplex-badge-service/README.md @@ -122,6 +122,10 @@ The BTCPay API key needs four permissions, each scoped to the one store: `canmodifyinvoices` so `POST /api/invoice/:id/cancel` can invalidate an invoice at BTCPay rather than only in this store. +Give the service a BTCPay store of its own, and a Stripe account of its own if card payments are +on. The poller lists every invoice and payment intent of the last three days or so, and one it did +not create and cannot read is reported in a warning once an hour until it falls out of that window. + ### Card payments (Stripe) An optional `[stripe]` section enables the card lane; omitting it disables card payments diff --git a/apps/simplex-badge-service/src/BadgeService/Poller.hs b/apps/simplex-badge-service/src/BadgeService/Poller.hs index c91ffcd22f..c22df44ddb 100644 --- a/apps/simplex-badge-service/src/BadgeService/Poller.hs +++ b/apps/simplex-badge-service/src/BadgeService/Poller.hs @@ -45,7 +45,7 @@ import qualified Data.Set as S import Data.Text (Text) import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime) import Numeric.Natural (Natural) -import Simplex.Chat.PaymentService.Types (InvoiceStatus (..), PaymentProvider) +import Simplex.Chat.PaymentService.Types (InvoiceStatus (..), PaymentProvider (..)) import Simplex.Messaging.Agent.Store.Common (DBStore) import Simplex.Messaging.Util (tshow) @@ -181,6 +181,7 @@ listPass env now p = pure False Right ListPass {lpMoved, lpSkipped} -> do owners <- mapM (\s -> safelyWith (skipWhat s) SkipUnaccounted (reportSkip env (pProvider p) now s)) lpSkipped + reportStrangers env (pProvider p) now [reason | (SkipStranger, (_, reason)) <- zip owners lpSkipped] settled <- mapM (\m -> safely (settleWhat m) (settleMoved env (pProvider p) now m)) lpMoved pure (all (== SkipStranger) owners && and settled) where @@ -290,13 +291,26 @@ skipOwner PollerEnv {peStore} provider = \case reportSkip :: PollerEnv -> PaymentProvider -> UTCTime -> (Maybe Text, Text) -> IO SkipOwner reportSkip env provider now (ref, reason) = do owner <- skipOwner env provider ref - due <- dueToWarn env now reason - when due $ case owner of - SkipOurs -> logError ("badge poller: an invoice this service sold was not read, so its payment cannot be detected: " <> reason) - SkipUnaccounted -> logError ("badge poller: part of the window was not read, so a payment to any invoice in it cannot be detected: " <> reason) - SkipStranger -> logWarn ("badge poller: the list pass could not read everything: " <> reason) + let raise msg = dueToWarn env now reason >>= (`when` logError (msg <> reason)) + case owner of + SkipOurs -> raise "badge poller: an invoice this service sold was not read, so its payment cannot be detected: " + SkipUnaccounted -> raise "badge poller: part of the window was not read, so a payment to any invoice in it cannot be detected: " + SkipStranger -> pure () pure owner +-- | One limiter key per provider, so a store full of invoices sold elsewhere costs one line an hour, not one per invoice. +reportStrangers :: PollerEnv -> PaymentProvider -> UTCTime -> [Text] -> IO () +reportStrangers env provider now = \case + [] -> pure () + reasons@(example : _) -> do + due <- dueToWarn env now ("stranger skips: " <> tshow provider) + when due $ logWarn ("badge poller: " <> tshow (length reasons) <> " unreadable invoice(s) not created by this service; use a dedicated " <> home <> "; first: " <> example) + where + home = case provider of + PPCrypto -> "BTCPay store" + PPStripe -> "Stripe account" + other -> providerText other <> " account" + dueToWarn :: PollerEnv -> UTCTime -> Text -> IO Bool dueToWarn PollerEnv {peSkipped} now reason = atomically $ do seen <- readTVar peSkipped diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg index 715a6daf99..fcf54b281e 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg index f70ecb91db..1dec1bd4ee 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg index 48bfe6caa7..b14749dc91 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg index 2bab828717..f1598e3732 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg index 704cae8710..70badd7f13 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg index d7c1f4e088..105f09d455 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg index e39e0836cb..0539fbe593 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg index ce9b4a86ef..ba7027bded 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg differ diff --git a/cabal.project b/cabal.project index d15e79a9aa..a35474ca56 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: bf391d60eca9813ceb2f7ef77d104960a463e46e + tag: 965ac7b1e339d8c172e70685a845c62cb6b0ec87 source-repository-package type: git 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. diff --git a/plans/desktop-bars-blur-performance.md b/plans/desktop-bars-blur-performance.md new file mode 100644 index 0000000000..4e113c90cb --- /dev/null +++ b/plans/desktop-bars-blur-performance.md @@ -0,0 +1,100 @@ +# Plan: make the desktop bars blur lightweight + +## Context + +Settings > Appearance > Blur blurs the app bars: `blurredBackgroundModifier` puts a `BlurEffect` on the bar's layer +and draws a copy of the scrolled content inside it, so the blur is recomputed every frame. On desktop this made +scrolling visibly laggy at the default radius of 50. + +## Measurements + +Measured on the software renderer (`SKIKO_RENDER_API=SOFTWARE`), 1376x768 window, density 1, scrolling a chat of 70 +messages. Frames were counted by capturing the window repeatedly and counting distinct images. + +| | blur off | blur 50 | +|---|---|---| +| visible frames per second while scrolling | 14.5 | 5.2 - 8.7 | + +A standalone harness that replays the same drawing through Skiko's `RenderNode` puts the cost of one bar at 29 ms +(radius 30), 42 ms (radius 50) and 71 ms (radius 100). Two bars are drawn in a chat, so at the default radius the +bars alone cost about 80 ms per frame. + +## Cause + +Skia's raster blur runs at full resolution: `Raster8888BlurAlgorithm` only rescales its input above sigma 135 +(`SkBlurEngine.cpp`), and every radius the slider offers is below that. Each bar therefore blurs +`width x (bar + 6 sigma)` pixels per frame, which at radius 100 is the full window width by 404 rows. + +The obvious remedy, blurring a downscaled copy the way Skia's own GPU path does, cannot be expressed by scaling the +layer: **a layer's image filter is evaluated in device space**, so any scale on the layer or the canvas is applied to +the sigma as well. Narrowing a bar eightfold and blurring it with an eighth of the sigma measured 68.5 ms against +68.4 ms for the untouched blur, and looked wrong, because the reduced sigma ended up applied at full resolution. +This is why the blur appeared weak enough to leave text under the bar readable. + +## Fix + +Draw the bar into an offscreen Skia surface that is up to 8x narrower, blur it there, where the surface's own +resolution is the device resolution and the sigma is not scaled back up, then stretch the finished blur across the +bar. Only the width is reduced: a bar is `AppBarHeight` tall, and taking rows away from it visibly weakens the blur, +which the harness confirmed and which was visible in the app. + +`drawBarsBlurred` is an `expect`/`actual` because it needs Skia surfaces directly. Android is untouched: it blurs +through a `RenderEffect` on the layer, where the GPU blur rescales internally, so it never had this cost. + +Blurring a copy has one consequence for when the bars draw. Previously the bar recorded `drawLayer(graphicsLayer)`, a +live reference that replays whatever the content layer holds at playback, so the bar did not have to be redrawn when +the content scrolled. Reading the content into a surface fixes it at the moment the bar draws instead, so the bar has +to be redrawn whenever that copy is re-recorded, which `AppBarHandler.contentVersion` provides: `copyViewToAppBar` +bumps it after each recording and the bar reads it. Without it the blur inside a chat lagged behind the content, +while the chat list looked correct because its bar happened to be invalidated by other state. + +The surfaces are kept per size rather than one pair overall, because the desktop window's panes have different +widths and their bars are drawn in the same frame, which would otherwise reallocate both surfaces for every bar. + +How far the copy is reduced follows from the blur itself. The width is divided until the blur's own sigma would fall +below 1.5, past which the narrowed copy is barely blurred and stretching it back out shows the steps of the narrowing +rather than a blur. The rows are halved once, with the vertical sigma halved to match. The rule has to be written in +terms of the reduced sigma rather than the radius: stopping as soon as the reduced sigma reached 4, as a first version +did, left the small radiuses barely narrowed at all and made radius 10 cost more than radius 50. + +The blur is the surface's own layer paint rather than a copy between two surfaces, which is both one allocation fewer +and, measured with the surfaces reused as the app reuses them, 3-11% quicker. + +Per bar, against the unmodified blur, with surfaces reused: + +| radius | before | after | difference from the original | +|---|---|---|---| +| 10 | 17.9 ms | 2.7 ms | 0.41 / 255 | +| 50 | 40.9 ms | 3.0 ms | 0.23 / 255 | +| 100 | 72.7 ms | 5.0 ms | 0.27 / 255 | + +The difference column is the root mean square difference over the bar, before the bar's own tint is applied, so what +reaches the screen is smaller still. Redrawing the bar and stretching the blur back over it cost 0.5 ms of those +figures whatever the radius, so that is the floor this approach can reach. + +## Keeping the bars in step with the content + +A layer's own filter follows the content it is attached to for free. Blurring a copy does not: the copy is taken when +the bar draws, so the bar has to be redrawn whenever the content it copies has moved. `AppBarHandler.contentVersion` +carries that, and the scroll containers bump it from a collector on the state they actually scroll. + +Two details are load-bearing. The bump must not happen while drawing: an earlier version bumped it inside +`copyViewToAppBar`, and because Compose Desktop redraws the whole scene per frame, the content's draw invalidated the +bar, which requested another frame, which drew the content again. Measured idle, with no interaction: **96% of a core** +against 0% with the blur off and 0% for the unmodified app. And the version has to come from the state the container +actually scrolls, not from the handler's own: a chat supplies its own `LazyListState`, so watching the handler's left +the chat's blur stale while the chat list looked correct. + +## Rejected + +Reusing the blurred copy while the content is unchanged was measured and dropped: it saves nothing while scrolling, +which is the case that lags, and the chat wallpaper is recorded into its own layer without touching the content +version, so a cached blur could outlast a wallpaper change. + +Blurring the bars of both panes in one pass does not apply: only `DefaultAppBar` is blurred, one per screen, and the +two on a desktop window belong to panes of different widths side by side rather than stacked. + +## Verification + +Built as an AppImage and checked by eye across the whole slider range at bar alpha 0.5, where the blur is most +exposed, on top of the chat list and inside a chat. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 4989df0bdf..b54f6ab354 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."bf391d60eca9813ceb2f7ef77d104960a463e46e" = "0fgq7xnjf7w03fjj49mal97g9mija5572mggb817ad0i2ds42z9i"; + "https://github.com/simplex-chat/simplexmq.git"."965ac7b1e339d8c172e70685a845c62cb6b0ec87" = "1c37fxbyk9nlwbqhnjzaavrvzvx7l6zprrx6cg9f72k0cyv210vh"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index b1ef8842b0..c25998b25a 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -443,10 +443,11 @@ xftpSndFileTransfer_ user file@(CryptoFile filePath cfArgs) fileSize n contactOr fInv = (xftpFileInvitation fileName fileSize dummyFileDescr :: FileInvitation) {fileBadge} fsFilePath <- lift $ toFSFilePath filePath let srcFile = CryptoFile fsFilePath cfArgs - aFileId <- withAgent $ \a -> xftpSendFile a (aUserId user) srcFile (roundedFDCount n) Nothing + aFileId <- withAgent $ \a -> xftpPrepareSendFile a (aUserId user) srcFile (roundedFDCount n) Nothing -- TODO CRSndFileStart event for XFTP chSize <- asks $ fileChunkSize . config ft@FileTransferMeta {fileId} <- withStore' $ \db -> createSndFileTransferXFTP db user contactOrGroup_ file fInv (AgentSndFileId aFileId) Nothing chSize + withAgent (`xftpStartSendFile` aFileId) let fileSource = Just $ CryptoFile filePath cfArgs ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP, fileExpires = Nothing, fileProhibited = Nothing} pure (fInv, ciFile, ft) @@ -485,9 +486,10 @@ xftpSndFileRedirect user ftId vfd = do let fileName = "redirect.yaml" file = CryptoFile fileName Nothing fInv = xftpFileInvitation fileName (fromIntegral $ B.length $ strEncode vfd) dummyFileDescr - aFileId <- withAgent $ \a -> xftpSendDescription a (aUserId user) vfd (roundedFDCount 1) + aFileId <- withAgent $ \a -> xftpPrepareSendDescription a (aUserId user) vfd (roundedFDCount 1) chSize <- asks $ fileChunkSize . config - withStore' $ \db -> createSndFileTransferXFTP db user Nothing file fInv (AgentSndFileId aFileId) (Just ftId) chSize + ft <- withStore' $ \db -> createSndFileTransferXFTP db user Nothing file fInv (AgentSndFileId aFileId) (Just ftId) chSize + ft <$ withAgent (`xftpStartSendFile` aFileId) dummyFileDescr :: FileDescr dummyFileDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} @@ -852,9 +854,10 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} where receive' :: ValidFileDescription 'FRecipient -> Bool -> CM () receive' rd approved = do - aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) rd cfArgs approved + aFileId <- withAgent $ \a -> xftpPrepareReceiveFile a (aUserId user) rd cfArgs approved startReceivingFile user fileId withStore' $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) + withAgent (`xftpStartReceiveFile` aFileId) getUnknownSrvs :: [XFTPServer] -> CM [XFTPServer] getUnknownSrvs srvs = do knownSrvs <- L.map protoServer' <$> getKnownAgentServers SPXFTP user @@ -905,13 +908,14 @@ receiveViaURI :: User -> FileDescriptionURI -> CryptoFile -> CM RcvFileTransfer receiveViaURI user@User {userId} FileDescriptionURI {description} cf@CryptoFile {cryptoArgs} = do fileId <- withStore $ \db -> createRcvStandaloneFileTransfer db userId cf fileSize chunkSize -- currently the only use case is user migrating via their configured servers, so we pass approvedRelays = True - aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) description cryptoArgs True - withStore $ \db -> do + aFileId <- withAgent $ \a -> xftpPrepareReceiveFile a (aUserId user) description cryptoArgs True + ft <- withStore $ \db -> do liftIO $ do updateRcvFileStatus db fileId FSConnected updateCIFileStatus db user fileId $ CIFSRcvTransfer 0 1 updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) getRcvFileTransfer db user fileId + ft <$ withAgent (`xftpStartReceiveFile` aFileId) where FD.ValidFileDescription FD.FileDescription {size = FD.FileSize fileSize, chunkSize = FD.FileSize chunkSize} = description diff --git a/tests/Bots/BadgeService/WebTests.hs b/tests/Bots/BadgeService/WebTests.hs index dd21c820dd..633ad14564 100644 --- a/tests/Bots/BadgeService/WebTests.hs +++ b/tests/Bots/BadgeService/WebTests.hs @@ -28,7 +28,7 @@ import qualified Control.Concurrent.Async as Async import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar) import Control.Concurrent.STM (atomically, modifyTVar', readTVarIO) import qualified Control.Exception as E -import Control.Monad (join, replicateM, replicateM_, void, when) +import Control.Monad (forM_, join, replicateM, replicateM_, void, when) import Data.Aeson ((.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Key as K @@ -237,6 +237,7 @@ badgeWebTests = do it "reports a skipped invoice once, and again only after the interval" testSkipWarningsAreRateLimited it "warns once for a provider that stays down, not once a pass" testOutageWarnsOnceNotEveryPass it "holds the skip log under its cap when every reason is fresh" testSkipReasonsStayBounded + it "warns once for all the invoices it did not sell, not once per invoice" testStrangerSkipsShareOneWarning it "raises a skip naming an invoice this service sold" testSkipNamingOurInvoiceIsRaised it "holds the sweep back until a pass has accounted for every invoice" testSweepWaitsForAPassThatSawEverything it "settles the rest of the pass around an invoice that throws" testOneBadInvoiceDoesNotStopThePass @@ -2665,13 +2666,19 @@ testSkipNamingOurInvoiceIsRaised = bounded "skip ownership" $ withStubPoller rac invoiceStatus (weStore env) iid `shouldReturn` ISPaid testSkipReasonsStayBounded :: IO () -testSkipReasonsStayBounded = bounded "skip reasons bounded" $ withStubPoller raceHold $ \ref poller _ _ -> do - let reasons n = [(Just ("p-" <> tshow i), "btcpay invoice p-" <> tshow i <> ": unknown method") | i <- [1 .. n :: Int]] - setSkipped ref (reasons (maxSkipReasons + 500)) +testSkipReasonsStayBounded = bounded "skip reasons bounded" $ withStubPoller raceHold $ \_ poller _ _ -> do + now <- getCurrentTime + forM_ [1 .. maxSkipReasons + 500] $ \i -> dueToWarn poller now ("btcpay invoice p-" <> tshow i <> ": unknown method") runOnePass poller held <- Map.size <$> readTVarIO (peSkipped poller) held `shouldSatisfy` (<= maxSkipReasons) +testStrangerSkipsShareOneWarning :: IO () +testStrangerSkipsShareOneWarning = bounded "stranger skips" $ withStubPoller raceHold $ \ref poller _ _ -> do + setSkipped ref [(Just ("stranger-" <> tshow i), "btcpay invoice stranger-" <> tshow i <> ": unknown method") | i <- [1 .. 3 :: Int]] + runOnePass poller + Map.size <$> readTVarIO (peSkipped poller) `shouldReturn` 1 + -- | The failure text carries the whole request, whose window moves with the clock, so the two -- messages differ only in startDate to prove the limiter key ignores it rather than warning -- every pass.