From 128eba3e5614205d780d1630fbbebc46497c2054 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 15 Jan 2026 23:25:02 +0700 Subject: [PATCH] fix(ui): implement stack blur for API < 31 fallback --- .../android/core/ui/utils/SpoilerEffect.kt | 268 ++++++++++++++++-- 1 file changed, 248 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/core/ui/utils/SpoilerEffect.kt b/app/src/main/java/com/bitchat/android/core/ui/utils/SpoilerEffect.kt index d04fa9c0..edb066b5 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/utils/SpoilerEffect.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/utils/SpoilerEffect.kt @@ -1,6 +1,8 @@ package com.bitchat.android.core.ui.utils import android.graphics.Bitmap +import android.graphics.BitmapShader +import android.graphics.Shader import android.os.Build import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -17,14 +19,13 @@ import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.asComposeRenderEffect -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt /** * Applies a spoiler effect to the content. @@ -54,16 +55,23 @@ fun Modifier.spoiler( val iconPainter = rememberVectorPainter(Icons.Filled.TouchApp) val iconColor = MaterialTheme.colorScheme.onSurface - // API < 31 Fallback: Generate a blurred bitmap if provided - val fallbackBlurredBitmap = remember(blurBitmap) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S && blurBitmap != null) { - // Perform a fast "Downscale Blur" (Box/Bilinear Filter simulation) - // Scale down to ~5% size then let the canvas upscale it with filtering + // API < 31 Fallback: Generate a blurred bitmap shader if provided + val fallbackShader = remember(blurBitmap) { + // Keep user's debug flags as requested + if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.S || true) && blurBitmap != null) { try { - val w = (blurBitmap.width * 0.05f).toInt().coerceAtLeast(1) - val h = (blurBitmap.height * 0.05f).toInt().coerceAtLeast(1) + // 1. Scale down to a small size for performance (e.g. 64px) + // This ensures the O(N) pixel loop is negligible even on low-end devices + val scale = 64f / blurBitmap.width.coerceAtLeast(1) + val w = (blurBitmap.width * scale).toInt().coerceAtLeast(1) + val h = (blurBitmap.height * scale).toInt().coerceAtLeast(1) val small = Bitmap.createScaledBitmap(blurBitmap, w, h, true) - small.asImageBitmap() + + // 2. Blur the pixels manually (StackBlur/FastBlur algorithm) + val blurred = fastBlur(small, 8) // Radius 8 on 64px image is very blurry + + // 3. Create a Shader from the result + BitmapShader(blurred, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) } catch (e: Exception) { null } @@ -84,14 +92,11 @@ fun Modifier.spoiler( // 2. graphicsLayer (Inner wrapper) -> Blurs the content (Image) .drawWithContent { // API < 31 Fallback Logic - if (fallbackBlurredBitmap != null && revealProgress < 1f) { - // Instead of drawing the sharp content, draw the blurred bitmap - // filling the drawing area - drawImage( - image = fallbackBlurredBitmap, - dstSize = IntSize(size.width.toInt(), size.height.toInt()), - dstOffset = IntOffset.Zero, - filterQuality = androidx.compose.ui.graphics.FilterQuality.Low // Ensure filtering happens + if (fallbackShader != null && revealProgress < 1f) { + // Draw the blurred shader covering the whole area + drawRect( + brush = ShaderBrush(fallbackShader), + alpha = 1f ) } else { drawContent() // Normal draw @@ -125,7 +130,8 @@ fun Modifier.spoiler( } // Blur Implementation (API 31+) .graphicsLayer { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Keep user's debug flag as requested (&& false) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && false) { val blurRadius = (40f * (1f - revealProgress)).coerceAtLeast(0f) * density if (blurRadius > 0) { @@ -140,3 +146,225 @@ fun Modifier.spoiler( } } } + +/** + * A fast approximation of Gaussian Blur (Stack Blur). + * Efficient enough to run on the main thread for small bitmaps. + */ +private fun fastBlur(sentBitmap: Bitmap, radius: Int): Bitmap { + val bitmap = sentBitmap.copy(sentBitmap.config ?: Bitmap.Config.ARGB_8888, true) + if (radius < 1) return (null ?: sentBitmap) + + val w = bitmap.width + val h = bitmap.height + val pix = IntArray(w * h) + bitmap.getPixels(pix, 0, w, 0, 0, w, h) + + val wm = w - 1 + val hm = h - 1 + val wh = w * h + val div = radius + radius + 1 + + val r = IntArray(wh) + val g = IntArray(wh) + val b = IntArray(wh) + var rsum: Int + var gsum: Int + var bsum: Int + var x: Int + var y: Int + var i: Int + var p: Int + var yp: Int + var yi: Int + val vmin = IntArray(Math.max(w, h)) + + var divsum = (div + 1) shr 1 + divsum *= divsum + val dv = IntArray(256 * divsum) + for (i in 0 until 256 * divsum) { + dv[i] = (i / divsum) + } + + yi = 0 + var yw = 0 + + val stack = Array(div) { IntArray(3) } + var stackpointer: Int + var stackstart: Int + var sir: IntArray + var rbs: Int + var r1 = radius + 1 + var routsum: Int + var goutsum: Int + var boutsum: Int + var rinsum: Int + var ginsum: Int + var binsum: Int + + for (y in 0 until h) { + rinsum = 0 + ginsum = 0 + binsum = 0 + routsum = 0 + goutsum = 0 + boutsum = 0 + rsum = 0 + gsum = 0 + bsum = 0 + for (i in -radius..radius) { + p = pix[yi + Math.min(wm, Math.max(i, 0))] + sir = stack[i + radius] + sir[0] = (p and 0xff0000) shr 16 + sir[1] = (p and 0x00ff00) shr 8 + sir[2] = (p and 0x0000ff) + rbs = r1 - Math.abs(i) + rsum += sir[0] * rbs + gsum += sir[1] * rbs + bsum += sir[2] * rbs + if (i > 0) { + rinsum += sir[0] + ginsum += sir[1] + binsum += sir[2] + } else { + routsum += sir[0] + goutsum += sir[1] + boutsum += sir[2] + } + } + stackpointer = radius + + for (x in 0 until w) { + r[yi] = dv[rsum] + g[yi] = dv[gsum] + b[yi] = dv[bsum] + + rsum -= routsum + gsum -= goutsum + bsum -= boutsum + + stackstart = stackpointer - radius + div + sir = stack[stackstart % div] + + routsum -= sir[0] + goutsum -= sir[1] + boutsum -= sir[2] + + if (y == 0) { + vmin[x] = Math.min(x + radius + 1, wm) + } + p = pix[yw + vmin[x]] + + sir[0] = (p and 0xff0000) shr 16 + sir[1] = (p and 0x00ff00) shr 8 + sir[2] = (p and 0x0000ff) + + rinsum += sir[0] + ginsum += sir[1] + binsum += sir[2] + + rsum += rinsum + gsum += ginsum + bsum += binsum + + stackpointer = (stackpointer + 1) % div + sir = stack[(stackpointer) % div] + + routsum += sir[0] + goutsum += sir[1] + boutsum += sir[2] + + rinsum -= sir[0] + ginsum -= sir[1] + binsum -= sir[2] + + yi++ + } + yw += w + } + + for (x in 0 until w) { + rinsum = 0 + ginsum = 0 + binsum = 0 + routsum = 0 + goutsum = 0 + boutsum = 0 + rsum = 0 + gsum = 0 + bsum = 0 + yp = -radius * w + for (i in -radius..radius) { + yi = Math.max(0, yp) + x + sir = stack[i + radius] + sir[0] = r[yi] + sir[1] = g[yi] + sir[2] = b[yi] + rbs = r1 - Math.abs(i) + rsum += r[yi] * rbs + gsum += g[yi] * rbs + bsum += b[yi] * rbs + if (i > 0) { + rinsum += sir[0] + ginsum += sir[1] + binsum += sir[2] + } else { + routsum += sir[0] + goutsum += sir[1] + boutsum += sir[2] + } + if (i < hm) { + yp += w + } + } + yi = x + stackpointer = radius + for (y in 0 until h) { + pix[yi] = (0xff000000.toInt() or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum]) + + rsum -= routsum + gsum -= goutsum + bsum -= boutsum + + stackstart = stackpointer - radius + div + sir = stack[stackstart % div] + + routsum -= sir[0] + goutsum -= sir[1] + boutsum -= sir[2] + + if (x == 0) { + vmin[y] = Math.min(y + r1, hm) * w + } + p = x + vmin[y] + + sir[0] = r[p] + sir[1] = g[p] + sir[2] = b[p] + + rinsum += sir[0] + ginsum += sir[1] + binsum += sir[2] + + rsum += rinsum + gsum += ginsum + bsum += binsum + + stackpointer = (stackpointer + 1) % div + sir = stack[stackpointer] + + routsum += sir[0] + goutsum += sir[1] + boutsum += sir[2] + + rinsum -= sir[0] + ginsum -= sir[1] + binsum -= sir[2] + + yi += w + } + } + + bitmap.setPixels(pix, 0, w, 0, 0, w, h) + return bitmap +}