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 new file mode 100644 index 00000000..5512d844 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/utils/SpoilerEffect.kt @@ -0,0 +1,384 @@ +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 +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +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.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.unit.dp +import androidx.core.graphics.scale +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Applies a spoiler effect to the content. + * + * @param isVisible Whether the spoiler (obscuration) is active. + * @param blurBitmap Optional bitmap to be blurred and displayed for API < 31. + * If null, a dimming overlay is used as fallback. + * @param onReveal Called when the user taps to reveal the content. + */ +fun Modifier.spoiler( + isVisible: Boolean, + blurBitmap: Bitmap? = null, + onReveal: () -> Unit +): Modifier = composed { + val revealProgress by animateFloatAsState( + targetValue = if (isVisible) 0f else 1f, + animationSpec = tween(durationMillis = 400), + label = "revealProgress" + ) + + // If fully revealed, just draw content to avoid overdraw/performance hit + if (revealProgress == 1f) { + return@composed this + } + + // Icon Logic + val iconPainter = rememberVectorPainter(Icons.Filled.TouchApp) + val iconColor = MaterialTheme.colorScheme.onSurface + + val blurRadius = (40f * (1f - revealProgress)).coerceAtLeast(0f) + val legacyBlurRadius = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + (blurRadius / 5f).roundToInt().coerceAtLeast(0) + } else { + 0 + } + + val legacySmallBitmap = remember(blurBitmap) { + if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.S) && blurBitmap != null) { + // Scale down to a small size for performance. + val scale = 64f / blurBitmap.width.coerceAtLeast(1) + val w = (blurBitmap.width * scale).toInt().coerceAtLeast(1) + val h = (blurBitmap.height * scale).toInt().coerceAtLeast(1) + blurBitmap.scale(w, h) + } else { + null + } + } + + // API < 31 Fallback: Generate a blurred bitmap shader if provided + val fallbackShader = remember(legacySmallBitmap, legacyBlurRadius) { + if (legacySmallBitmap != null && legacyBlurRadius > 0) { + try { + val blurred = fastBlur(legacySmallBitmap, legacyBlurRadius) + BitmapShader(blurred, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + } catch (e: Exception) { + null + } + } else { + null + } + } + + this + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, // No ripple for the spoiler tap itself to keep it clean + enabled = isVisible, + onClick = onReveal + ) + // Order matters: + // 1. drawWithContent (Outer wrapper) -> Draws overlays ON TOP of everything else + // 2. graphicsLayer (Inner wrapper) -> Blurs the content (Image) + .drawWithContent { + // API < 31 Fallback Logic + if (fallbackShader != null && revealProgress < 1f) { + // Draw the blurred shader covering the whole area + drawRect( + brush = ShaderBrush(fallbackShader), + alpha = 1f + ) + } else { + drawContent() // Normal draw + } + + // Calculate overlay opacity (fades out as revealed) + val overlayAlpha = (1f - revealProgress).coerceIn(0f, 1f) + + if (overlayAlpha > 0f) { + // 1. Draw Dimming Overlay + // Necessary to ensure icon visibility against light images + drawRect( + color = Color.Black.copy(alpha = 0.4f * overlayAlpha) + ) + + // 2. Draw "TouchApp" Icon + val iconSize = 48.dp.toPx() + val iconX = (size.width - iconSize) / 2 + val iconY = (size.height - iconSize) / 2 + + translate(left = iconX, top = iconY) { + with(iconPainter) { + draw( + size = Size(iconSize, iconSize), + alpha = overlayAlpha, + colorFilter = ColorFilter.tint(iconColor) + ) + } + } + } + } + // Blur Implementation (API 31+) + .graphicsLayer { + // Keep user's debug flag as requested (&& false) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val blurRadiusPx = blurRadius * density + + renderEffect = if (blurRadiusPx > 0) { + android.graphics.RenderEffect + .createBlurEffect( + blurRadiusPx, + blurRadiusPx, + Shader.TileMode.CLAMP + ) + .asComposeRenderEffect() + } else { + null + } + } else { + renderEffect = null + } + } +} + +/** + * 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(w.coerceAtLeast(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 + val 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 + wm.coerceAtMost(i.coerceAtLeast(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 - 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] = (x + radius + 1).coerceAtMost(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 = 0.coerceAtLeast(yp) + x + sir = stack[i + radius] + sir[0] = r[yi] + sir[1] = g[yi] + sir[2] = b[yi] + rbs = r1 - 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] = (y + r1).coerceAtMost(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 +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 43ae84f9..91d94ee8 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -31,6 +31,8 @@ import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.saveable.rememberSaveable +import com.bitchat.android.core.ui.utils.spoiler import java.text.SimpleDateFormat import java.util.* @@ -93,6 +95,8 @@ fun ImageMessageItem( is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> if (st.total > 0) st.reached.toFloat() / st.total.toFloat() else 0f else -> null } + var isSpoilerVisible by rememberSaveable(key = message.id) { mutableStateOf(true) } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { Box { if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) { @@ -106,10 +110,11 @@ fun ImageMessageItem( .widthIn(max = 300.dp) .aspectRatio(aspect) .clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp)) - .clickable { + .clickable(enabled = !isSpoilerVisible) { val currentIndex = imagePaths.indexOf(path) onImageClick?.invoke(path, imagePaths, currentIndex) } + .spoiler(isVisible = isSpoilerVisible, blurBitmap = bmp) { isSpoilerVisible = false } ) } else { // Fully revealed image @@ -120,10 +125,11 @@ fun ImageMessageItem( .widthIn(max = 300.dp) .aspectRatio(aspect) .clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp)) - .clickable { + .clickable(enabled = !isSpoilerVisible) { val currentIndex = imagePaths.indexOf(path) onImageClick?.invoke(path, imagePaths, currentIndex) - }, + } + .spoiler(isVisible = isSpoilerVisible) { isSpoilerVisible = false }, contentScale = ContentScale.Fit ) }