mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
Merge 979e6ba9fb09edce57f3371e40d76c9452001726 into c8f45408b1984753a4df7f05aff03ceaa17bbba6
This commit is contained in:
commit
661da6a16d
@ -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
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user