feat: implement CPU-based metaball rendering and performance classification

This commit is contained in:
k1ngsterr1
2026-02-07 05:32:39 +05:00
parent ec4259492b
commit 8b5db46b3a
5 changed files with 902 additions and 147 deletions

View File

@@ -0,0 +1,204 @@
package com.rosetta.messenger.ui.components.metaball
import android.graphics.Bitmap
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
/**
* Proper Stack Blur algorithm by Mario Klingemann.
* Uses a triangular (tent) kernel — each pixel is weighted by (radius + 1 - distance).
* Unlike box blur (simple moving average), produces smooth alpha gradients
* essential for clean alpha-threshold gooey effects.
*
* Used by ProfileMetaballOverlayCpu for devices without RenderEffect (API < 31).
*/
internal fun stackBlurBitmap(source: Bitmap, radius: Int): Bitmap {
if (radius < 1) return source
val bitmap = source.copy(source.config, true)
stackBlurBitmapInPlace(bitmap, radius)
return bitmap
}
/**
* In-place stack blur on a mutable bitmap.
*/
internal fun stackBlurBitmapInPlace(bitmap: Bitmap, radius: Int) {
if (radius < 1) return
val w = bitmap.width
val h = bitmap.height
val pix = IntArray(w * h)
bitmap.getPixels(pix, 0, w, 0, 0, w, h)
stackBlurPixels(pix, w, h, radius)
bitmap.setPixels(pix, 0, w, 0, 0, w, h)
}
/**
* Core stack blur on raw ARGB pixel array.
* Two-pass (horizontal + vertical) with triangular weight kernel.
* Divisor = (radius+1)^2, matching the sum of triangular weights.
*/
private fun stackBlurPixels(pix: IntArray, w: Int, h: Int, radius: Int) {
val wm = w - 1
val hm = h - 1
val wh = w * h
val div = 2 * radius + 1
// Separate channel arrays for inter-pass storage
val aArr = IntArray(wh)
val rArr = IntArray(wh)
val gArr = IntArray(wh)
val bArr = IntArray(wh)
val vmin = IntArray(max(w, h))
// Stack blur divisor: sum of triangular weights = (radius+1)^2
val r1 = radius + 1
val mulSum = r1 * r1
// Circular buffer: each entry = [a, r, g, b]
val stack = Array(div) { IntArray(4) }
var yi = 0
var yw = 0
// === HORIZONTAL PASS ===
for (y in 0 until h) {
var asum = 0; var rsum = 0; var gsum = 0; var bsum = 0
var ainsum = 0; var rinsum = 0; var ginsum = 0; var binsum = 0
var aoutsum = 0; var routsum = 0; var goutsum = 0; var boutsum = 0
// Initialize stack with edge-clamped pixels, weighted by distance from center
for (i in -radius..radius) {
val p = pix[yi + min(wm, max(i, 0))]
val sir = stack[i + radius]
sir[0] = (p shr 24) and 0xff
sir[1] = (p shr 16) and 0xff
sir[2] = (p shr 8) and 0xff
sir[3] = p and 0xff
val rbs = r1 - abs(i) // triangular weight
asum += sir[0] * rbs
rsum += sir[1] * rbs
gsum += sir[2] * rbs
bsum += sir[3] * rbs
if (i > 0) {
ainsum += sir[0]; rinsum += sir[1]; ginsum += sir[2]; binsum += sir[3]
} else {
aoutsum += sir[0]; routsum += sir[1]; goutsum += sir[2]; boutsum += sir[3]
}
}
var stackpointer = radius
for (x in 0 until w) {
aArr[yi] = asum / mulSum
rArr[yi] = rsum / mulSum
gArr[yi] = gsum / mulSum
bArr[yi] = bsum / mulSum
asum -= aoutsum; rsum -= routsum; gsum -= goutsum; bsum -= boutsum
val stackstart = stackpointer - radius + div
val sir = stack[stackstart % div]
aoutsum -= sir[0]; routsum -= sir[1]; goutsum -= sir[2]; boutsum -= sir[3]
if (y == 0) {
vmin[x] = min(x + radius + 1, wm)
}
val p = pix[yw + vmin[x]]
sir[0] = (p shr 24) and 0xff
sir[1] = (p shr 16) and 0xff
sir[2] = (p shr 8) and 0xff
sir[3] = p and 0xff
ainsum += sir[0]; rinsum += sir[1]; ginsum += sir[2]; binsum += sir[3]
asum += ainsum; rsum += rinsum; gsum += ginsum; bsum += binsum
stackpointer = (stackpointer + 1) % div
val sir2 = stack[stackpointer % div]
aoutsum += sir2[0]; routsum += sir2[1]; goutsum += sir2[2]; boutsum += sir2[3]
ainsum -= sir2[0]; rinsum -= sir2[1]; ginsum -= sir2[2]; binsum -= sir2[3]
yi++
}
yw += w
}
// === VERTICAL PASS ===
for (x in 0 until w) {
var asum = 0; var rsum = 0; var gsum = 0; var bsum = 0
var ainsum = 0; var rinsum = 0; var ginsum = 0; var binsum = 0
var aoutsum = 0; var routsum = 0; var goutsum = 0; var boutsum = 0
var yp = -radius * w
for (i in -radius..radius) {
yi = max(0, yp) + x
val sir = stack[i + radius]
sir[0] = aArr[yi]
sir[1] = rArr[yi]
sir[2] = gArr[yi]
sir[3] = bArr[yi]
val rbs = r1 - abs(i)
asum += sir[0] * rbs
rsum += sir[1] * rbs
gsum += sir[2] * rbs
bsum += sir[3] * rbs
if (i > 0) {
ainsum += sir[0]; rinsum += sir[1]; ginsum += sir[2]; binsum += sir[3]
} else {
aoutsum += sir[0]; routsum += sir[1]; goutsum += sir[2]; boutsum += sir[3]
}
if (i < hm) {
yp += w
}
}
yi = x
var stackpointer = radius
for (y in 0 until h) {
pix[yi] = (min(255, asum / mulSum) shl 24) or
(min(255, rsum / mulSum) shl 16) or
(min(255, gsum / mulSum) shl 8) or
min(255, bsum / mulSum)
asum -= aoutsum; rsum -= routsum; gsum -= goutsum; bsum -= boutsum
val stackstart = stackpointer - radius + div
val sir = stack[stackstart % div]
aoutsum -= sir[0]; routsum -= sir[1]; goutsum -= sir[2]; boutsum -= sir[3]
if (x == 0) {
vmin[y] = min(y + r1, hm) * w
}
val p = x + vmin[y]
sir[0] = aArr[p]
sir[1] = rArr[p]
sir[2] = gArr[p]
sir[3] = bArr[p]
ainsum += sir[0]; rinsum += sir[1]; ginsum += sir[2]; binsum += sir[3]
asum += ainsum; rsum += rinsum; gsum += ginsum; bsum += binsum
stackpointer = (stackpointer + 1) % div
val sir2 = stack[stackpointer % div]
aoutsum += sir2[0]; routsum += sir2[1]; goutsum += sir2[2]; boutsum += sir2[3]
ainsum -= sir2[0]; rinsum -= sir2[1]; ginsum -= sir2[2]; binsum -= sir2[3]
yi += w
}
}
}

View File

@@ -0,0 +1,92 @@
package com.rosetta.messenger.ui.components.metaball
import android.app.ActivityManager
import android.content.Context
import android.os.Build
import android.util.Log
import java.io.RandomAccessFile
import java.util.Locale
import kotlin.math.ceil
/**
* Device performance classification - port of Telegram's SharedConfig.measureDevicePerformanceClass()
* Used to select the appropriate metaball rendering tier:
* - HIGH: GPU path (Android 12+) or CPU path (older Android)
* - AVERAGE: GPU path with reduced quality (Android 12+) or Noop (older)
* - LOW: Noop (no metaball effect)
*/
enum class PerformanceClass {
LOW, AVERAGE, HIGH
}
object DevicePerformanceClass {
@Volatile
private var cached: PerformanceClass? = null
fun get(context: Context): PerformanceClass {
cached?.let { return it }
return measure(context).also {
cached = it
Log.d("DevicePerformance", "Performance class: $it")
}
}
private fun measure(context: Context): PerformanceClass {
val androidVersion = Build.VERSION.SDK_INT
val cpuCount = Runtime.getRuntime().availableProcessors()
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memoryClass = am.memoryClass
// Read max CPU frequency
var totalCpuFreq = 0
var freqResolved = 0
for (i in 0 until cpuCount) {
try {
val reader = RandomAccessFile(
String.format(
Locale.ENGLISH,
"/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", i
), "r"
)
val line = reader.readLine()
if (line != null) {
totalCpuFreq += line.trim().toInt() / 1000
freqResolved++
}
reader.close()
} catch (_: Throwable) {
}
}
val maxCpuFreq = if (freqResolved == 0) -1
else ceil(totalCpuFreq.toFloat() / freqResolved).toInt()
// Read total RAM
val ram: Long = try {
val memInfo = ActivityManager.MemoryInfo()
am.getMemoryInfo(memInfo)
memInfo.totalMem
} catch (_: Exception) {
-1L
}
// Classification thresholds from Telegram's SharedConfig.measureDevicePerformanceClass()
return when {
androidVersion < 21 ||
cpuCount <= 2 ||
memoryClass <= 100 ||
(cpuCount <= 4 && maxCpuFreq != -1 && maxCpuFreq <= 1250) ||
(cpuCount <= 4 && maxCpuFreq <= 1600 && memoryClass <= 128 && androidVersion <= 21) ||
(cpuCount <= 4 && maxCpuFreq <= 1300 && memoryClass <= 128 && androidVersion <= 24) ||
(ram != -1L && ram < 2L * 1024L * 1024L * 1024L)
-> PerformanceClass.LOW
cpuCount < 8 ||
memoryClass <= 160 ||
(maxCpuFreq != -1 && maxCpuFreq <= 2055) ||
(maxCpuFreq == -1 && cpuCount == 8 && androidVersion <= 23)
-> PerformanceClass.AVERAGE
else -> PerformanceClass.HIGH
}
}
}

View File

@@ -1,74 +1,52 @@
package com.rosetta.messenger.ui.components.metaball
import android.graphics.ColorMatrixColorFilter
import android.graphics.RenderEffect
import android.graphics.RuntimeShader
import android.graphics.Shader
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asComposeRenderEffect
import androidx.compose.ui.graphics.graphicsLayer
import org.intellij.lang.annotations.Language
/**
* AGSL Shader for metaball effect
*
* This shader creates the "liquid merge" effect by:
* 1. Taking already-blurred content as input
* 2. Applying alpha threshold cutoff (alpha > cutoff = opaque, else transparent)
*
* When two blurred shapes overlap, their alpha values combine,
* exceeding the threshold and creating a merged appearance.
* Alpha threshold color matrix for metaball effect.
* Same as GPU_ALPHA_THRESHOLD_MATRIX in ProfileMetaballOverlay:
* newAlpha = oldAlpha * 51 - 6375 → values above ~125/255 become opaque.
* Works on API 31+ (Android 12) — no AGSL/RuntimeShader needed.
*/
@Language("AGSL")
const val MetaballShaderSource = """
uniform shader composable;
uniform float cutoff;
half4 main(float2 fragCoord) {
half4 color = composable.eval(fragCoord);
float alpha = color.a;
// Hard threshold: if alpha > cutoff, make fully opaque, else transparent
if (alpha > cutoff) {
alpha = 1.0;
} else {
alpha = 0.0;
}
// Return color with modified alpha
return half4(color.r, color.g, color.b, alpha);
}
"""
private val METABALL_ALPHA_THRESHOLD_MATRIX = floatArrayOf(
1f, 0f, 0f, 0f, 0f,
0f, 1f, 0f, 0f, 0f,
0f, 0f, 1f, 0f, 0f,
0f, 0f, 0f, 51f, 51f * -125f
)
/**
* Container that applies the metaball shader effect to its children.
*
* Container that applies the metaball alpha-threshold effect to its children.
*
* Children should use [customBlur] modifier to be part of the metaball effect.
* When blurred shapes overlap, they appear to "merge" like liquid blobs.
*
* When blurred shapes overlap, their alpha values combine,
* exceeding the threshold and creating a merged "liquid" appearance.
*
* Uses ColorMatrixColorFilter instead of AGSL shader — works on API 31+ (Android 12).
*
* @param modifier Modifier for the container
* @param cutoff Alpha threshold (0-1). Higher = harder edges. Default 0.5
* @param content Composable content (should contain MetaEntity elements)
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@RequiresApi(Build.VERSION_CODES.S)
@Composable
fun MetaContainer(
modifier: Modifier = Modifier,
cutoff: Float = 0.5f,
content: @Composable BoxScope.() -> Unit,
) {
val metaShader = remember { RuntimeShader(MetaballShaderSource) }
Box(
modifier = modifier.graphicsLayer {
metaShader.setFloatUniform("cutoff", cutoff)
renderEffect = RenderEffect.createRuntimeShaderEffect(
metaShader, "composable"
renderEffect = RenderEffect.createColorFilterEffect(
ColorMatrixColorFilter(METABALL_ALPHA_THRESHOLD_MATRIX)
).asComposeRenderEffect()
},
content = content,
@@ -77,10 +55,10 @@ fun MetaContainer(
/**
* Modifier that applies GPU-accelerated blur effect.
*
*
* Use this on shapes inside [MetaContainer] to create the metaball effect.
* The blur radius determines how "soft" the edges are before threshold is applied.
*
*
* @param blur Blur radius in pixels. Higher = softer edges, larger merge area
*/
@RequiresApi(Build.VERSION_CODES.S)
@@ -100,11 +78,11 @@ fun Modifier.customBlur(blur: Float) = this.then(
/**
* A composable that wraps content with metaball-capable blur.
*
*
* This creates a layered effect:
* - metaContent: The blurred shape that participates in metaball merging
* - content: The actual visible content (not blurred)
*
*
* @param modifier Modifier for the entity
* @param blur Blur radius for the metaball effect
* @param metaContent The shape that will be blurred and merged (usually a solid color Box)
@@ -119,12 +97,10 @@ fun MetaEntity(
content: @Composable BoxScope.() -> Unit = {},
) {
Box(modifier = modifier) {
// Blurred layer for metaball effect
Box(
modifier = Modifier.customBlur(blur),
content = metaContent,
)
// Visible content on top
content()
}
}

View File

@@ -1,28 +1,42 @@
package com.rosetta.messenger.ui.components.metaball
import android.graphics.ColorMatrixColorFilter
import android.graphics.Path
import android.graphics.RectF
import android.util.Log
import android.graphics.RenderEffect
import android.graphics.RuntimeShader
import android.graphics.Shader
import android.os.Build
import android.view.Gravity
import androidx.annotation.RequiresApi
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -33,9 +47,10 @@ import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.intellij.lang.annotations.Language
import androidx.compose.ui.unit.sp
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@@ -94,7 +109,6 @@ object ProfileMetaballConstants {
// Blur settings for gooey effect (like Telegram's intensity = 15f)
const val BLUR_RADIUS = 15f
const val CUTOFF = 0.5f
// Blur range for avatar (like Telegram: 2 + (1-fraction) * 20)
const val BLUR_MIN = 2f
@@ -108,28 +122,27 @@ object ProfileMetaballConstants {
}
/**
* AGSL Shader for metaball alpha threshold effect
* Same as Telegram's ColorMatrixColorFilter with alpha threshold
* GPU alpha threshold via ColorMatrixColorFilter (API 31+)
* Replaces AGSL RuntimeShader — same gooey effect, works on Android 12+
*
* From Telegram's ProfileGooeyView.GPUImpl:
* Alpha row: newAlpha = oldAlpha * 51 - 6375
* Values above ~125/255 become opaque, below become transparent.
* Equivalent to AGSL cutoff=0.5 but without requiring API 33.
*/
@Language("AGSL")
private const val ProfileMetaballShaderSource = """
uniform shader composable;
uniform float cutoff;
half4 main(float2 fragCoord) {
half4 color = composable.eval(fragCoord);
float alpha = color.a;
// Hard threshold for gooey effect
if (alpha > cutoff) {
alpha = 1.0;
} else {
alpha = 0.0;
}
return half4(color.r, color.g, color.b, alpha);
}
"""
private val GPU_ALPHA_THRESHOLD_MATRIX = floatArrayOf(
1f, 0f, 0f, 0f, 0f,
0f, 1f, 0f, 0f, 0f,
0f, 0f, 1f, 0f, 0f,
0f, 0f, 0f, 51f, 51f * -125f
)
/**
* No-notch fallback: black bar height at the top of the screen.
* Matches Telegram's BLACK_KING_BAR = 32dp.
* When no notch is detected, the avatar merges into this bar instead.
*/
private const val BLACK_BAR_HEIGHT_DP = 32f
/**
* State for avatar position and size during animation
@@ -336,7 +349,7 @@ private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float {
*
* IMPORTANT: Blur is applied ONLY to the metaball shapes, NOT to the avatar content!
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@RequiresApi(Build.VERSION_CODES.S)
@Composable
fun ProfileMetaballOverlay(
collapseProgress: Float,
@@ -345,6 +358,7 @@ fun ProfileMetaballOverlay(
headerHeight: Dp,
hasAvatar: Boolean,
@Suppress("UNUSED_PARAMETER") avatarColor: ComposeColor,
factorMult: Float = 1f,
modifier: Modifier = Modifier,
avatarContent: @Composable BoxScope.() -> Unit = {},
) {
@@ -445,27 +459,26 @@ fun ProfileMetaballOverlay(
}
}
// Metaball shader
val metaShader = remember { RuntimeShader(ProfileMetaballShaderSource) }
// Path for metaball connector
val metaballPath = remember { Path() }
val c1 = remember { Point() } // Notch center
val c2 = remember { Point() } // Avatar center
// 🔥 Reusable RectF like Telegram's AndroidUtilities.rectTmp
// Avoid creating new RectF objects every frame
// Reusable RectF like Telegram's AndroidUtilities.rectTmp
val rectTmp = remember { RectF() }
// Black bar height for no-notch fallback (Telegram's BLACK_KING_BAR)
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
// Detect if device has a real centered notch (debug override supported)
val hasRealNotch = !MetaballDebug.forceNoNotch &&
notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0
// Calculate "v" parameter - thickness of connector based on distance
// Like Telegram: v = clamp((1f - c / 1.3f) / 2f, 0.8f, 0)
val distance = avatarState.centerY - notchCenterY
val maxDist = avatarSizeExpandedPx
val c = (distance / maxDist).coerceIn(-1f, 1f)
// Like Telegram: when NOT near, reduce v
// float near = isNear ? 1f : 1f - (vr - dp(32)) / dp(40 - 32);
// v = Math.min(lerp(0f, 0.2f, near), v);
val baseV = ((1f - c / 1.3f) / 2f).coerceIn(0f, 0.8f)
val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
val v = if (!avatarState.isNear) {
@@ -473,37 +486,37 @@ fun ProfileMetaballOverlay(
} else {
baseV
}
// Show connector only when avatar is small enough (like Telegram isDrawing)
// AND not when expanding (no metaball effect when expanded)
// AND only when hasAvatar is true (no drop animation for placeholder)
// AND only when device has real centered notch (Dynamic Island, punch-hole camera)
val hasRealNotch = notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0
val showConnector = hasRealNotch && hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
// Don't show black metaball shapes when expanded or when no avatar or no real notch
val showMetaballLayer = hasRealNotch && hasAvatar && expansionProgress == 0f
// Show connector when avatar is small enough (isDrawing) and not expanding
// No longer requires hasRealNotch — works with black bar fallback too
val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
// Show metaball layer when collapsing with avatar
val showMetaballLayer = hasAvatar && expansionProgress == 0f
// Adjusted blur radius based on device performance (factorMult)
val adjustedBlurRadius = ProfileMetaballConstants.BLUR_RADIUS / factorMult
Box(modifier = modifier
.fillMaxSize()
.clip(RectangleShape) // Clip to bounds - prevents avatar overflow when expanded
.clip(RectangleShape)
) {
// LAYER 1: Metaball shapes with blur effect (BLACK shapes only)
// HIDDEN when expanded - only show avatar content
// LAYER 1: Metaball shapes with blur + alpha threshold (BLACK shapes only)
if (showMetaballLayer) {
Canvas(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
metaShader.setFloatUniform("cutoff", ProfileMetaballConstants.CUTOFF)
// IMPORTANT: First blur, THEN threshold shader
// createChainEffect(outer, inner) - inner is applied first
// ColorMatrixColorFilter for alpha threshold (API 31+)
// Replaces AGSL RuntimeShader — same gooey effect
val blurEffect = RenderEffect.createBlurEffect(
ProfileMetaballConstants.BLUR_RADIUS,
ProfileMetaballConstants.BLUR_RADIUS,
adjustedBlurRadius,
adjustedBlurRadius,
Shader.TileMode.DECAL
)
val thresholdEffect = RenderEffect.createRuntimeShaderEffect(metaShader, "composable")
val thresholdEffect = RenderEffect.createColorFilterEffect(
ColorMatrixColorFilter(GPU_ALPHA_THRESHOLD_MATRIX)
)
// Chain: blur first (inner), then threshold (outer)
renderEffect = RenderEffect.createChainEffect(thresholdEffect, blurEffect)
.asComposeRenderEffect()
@@ -513,36 +526,29 @@ fun ProfileMetaballOverlay(
color = android.graphics.Color.BLACK
style = android.graphics.Paint.Style.FILL
}
drawIntoCanvas { canvas ->
val nativeCanvas = canvas.nativeCanvas
// Draw notch/camera circle (small circle at top)
// Draw target shape at top (notch or black bar fallback)
if (showConnector) {
if (notchInfo != null && notchInfo.isLikelyCircle) {
// Draw circle at actual notch position
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) {
nativeCanvas.drawCircle(
notchCenterX,
notchCenterY,
notchRadiusPx,
paint
)
} else if (notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) {
// Draw actual notch path
} else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) {
nativeCanvas.drawPath(notchInfo.path, paint)
} else if (notchInfo != null) {
// Draw rounded rect for non-accurate notch
} else if (hasRealNotch && notchInfo != null) {
val bounds = notchInfo.bounds
val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f
nativeCanvas.drawRoundRect(bounds, rad, rad, paint)
} else {
// No notch - draw small circle at status bar center
nativeCanvas.drawCircle(
screenWidthPx / 2f,
statusBarHeightPx / 2f,
notchRadiusPx,
paint
)
// No notch fallback: full-width black bar at top
// Like Telegram's ProfileGooeyView when notchInfo == null
nativeCanvas.drawRect(0f, 0f, screenWidthPx, blackBarHeightPx, paint)
}
}
@@ -699,8 +705,10 @@ fun ProfileMetaballOverlayCompat(
val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() }
// Fallback notch values for compat mode
val notchCenterX = screenWidthPx / 2f // Center of screen for compat mode
val notchCenterY = statusBarHeightPx / 2f
// Use black bar center as target (like Telegram's BLACK_KING_BAR)
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
val notchCenterX = screenWidthPx / 2f
val notchCenterY = blackBarHeightPx / 2f
val notchRadiusPx = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() }
// Telegram thresholds in pixels
@@ -804,7 +812,432 @@ fun ProfileMetaballOverlayCompat(
}
/**
* Auto-selecting wrapper based on Android version
* CPU alpha threshold matrix — from Telegram's ProfileGooeyView.CPUImpl.
* Operates on downscaled black shapes:
* Alpha row: newAlpha = oldAlpha * 60 - 7500
* Threshold at alpha ~125/255. RGB forced to 0 (black mask).
*/
private val CPU_ALPHA_THRESHOLD_MATRIX = floatArrayOf(
0f, 0f, 0f, 0f, 0f,
0f, 0f, 0f, 0f, 0f,
0f, 0f, 0f, 0f, 0f,
0f, 0f, 0f, 51f, -6375f // Match GPU threshold: cutoff at alpha ~125/255
)
/**
* CPU path for metaball effect — works on any Android version.
* Matches Telegram's ProfileGooeyView.CPUImpl:
* 1. Draw black shapes (target + avatar + connector) into a downscaled bitmap
* 2. Apply stack blur on CPU
* 3. Draw with ColorMatrixColorFilter for alpha threshold
* 4. Composite with SRC_ATOP
*/
@Composable
fun ProfileMetaballOverlayCpu(
collapseProgress: Float,
expansionProgress: Float,
statusBarHeight: Dp,
headerHeight: Dp,
hasAvatar: Boolean,
@Suppress("UNUSED_PARAMETER") avatarColor: ComposeColor,
modifier: Modifier = Modifier,
avatarContent: @Composable BoxScope.() -> Unit = {},
) {
val context = LocalContext.current
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.dp
val density = LocalDensity.current
val screenWidthPx = with(density) { screenWidth.toPx() }
val statusBarHeightPx = with(density) { statusBarHeight.toPx() }
val headerHeightPx = with(density) { headerHeight.toPx() }
val avatarSizeExpandedPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() }
val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() }
// Get notch info (debug override supported)
val notchInfo = remember { NotchInfoUtils.getInfo(context) }
val hasRealNotch = !MetaballDebug.forceNoNotch &&
notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
val notchRadiusPx = remember(notchInfo) {
if (hasRealNotch && notchInfo != null) {
if (notchInfo.isLikelyCircle) {
min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f
} else {
kotlin.math.max(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f
}
} else {
with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() }
}
}
val notchCenterX = remember(notchInfo, screenWidthPx) {
if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerX() else screenWidthPx / 2f
}
val notchCenterY = remember(notchInfo) {
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) {
notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f
} else if (hasRealNotch && notchInfo != null) {
notchInfo.bounds.centerY()
} else {
blackBarHeightPx / 2f
}
}
// Thresholds
val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() }
val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() }
val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() }
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() }
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() }
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX, notchCenterY, notchRadiusPx) {
derivedStateOf {
computeAvatarState(
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
screenWidthPx = screenWidthPx,
statusBarHeightPx = statusBarHeightPx,
headerHeightPx = headerHeightPx,
avatarSizeExpandedPx = avatarSizeExpandedPx,
avatarSizeMinPx = avatarSizeMinPx,
hasAvatar = hasAvatar,
notchCenterX = notchCenterX,
notchCenterY = notchCenterY,
notchRadiusPx = notchRadiusPx,
dp40 = dp40, dp34 = dp34, dp32 = dp32, dp18 = dp18, dp22 = dp22,
fullCornerRadius = avatarSizeExpandedPx / 2f
)
}
}
// Reusable objects
val metaballPath = remember { android.graphics.Path() }
val c1 = remember { Point() }
val c2 = remember { Point() }
val rectTmp = remember { RectF() }
// Connector calculations
val distance = avatarState.centerY - notchCenterY
val maxDist = avatarSizeExpandedPx
val cParam = (distance / maxDist).coerceIn(-1f, 1f)
val baseV = ((1f - cParam / 1.3f) / 2f).coerceIn(0f, 0.8f)
val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
val v = if (!avatarState.isNear) min(lerpFloat(0f, 0.2f, near), baseV) else baseV
val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
val showMetaballLayer = hasAvatar && expansionProgress == 0f
// CPU-specific: downscaled bitmap for blur
// Reduced from 5 to 3 for higher resolution — smoother edges after alpha threshold
val scaleConst = 3f
val optimizedW = min(with(density) { 120.dp.toPx() }.toInt(), screenWidthPx.toInt())
val optimizedH = min(with(density) { 220.dp.toPx() }.toInt(), headerHeightPx.toInt() + blackBarHeightPx.toInt())
val bitmapW = (optimizedW / scaleConst).toInt().coerceAtLeast(1)
val bitmapH = (optimizedH / scaleConst).toInt().coerceAtLeast(1)
val offscreenBitmap = remember(bitmapW, bitmapH) {
android.graphics.Bitmap.createBitmap(bitmapW, bitmapH, android.graphics.Bitmap.Config.ARGB_8888)
}
val offscreenCanvas = remember(offscreenBitmap) {
android.graphics.Canvas(offscreenBitmap)
}
// Reusable paints
val blackPaint = remember {
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
color = android.graphics.Color.BLACK
style = android.graphics.Paint.Style.FILL
}
}
val filterPaint = remember {
android.graphics.Paint(android.graphics.Paint.FILTER_BITMAP_FLAG or android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
isFilterBitmap = true
colorFilter = ColorMatrixColorFilter(CPU_ALPHA_THRESHOLD_MATRIX)
}
}
// Cleanup bitmap on dispose
DisposableEffect(offscreenBitmap) {
onDispose {
if (!offscreenBitmap.isRecycled) {
offscreenBitmap.recycle()
}
}
}
Box(modifier = modifier
.fillMaxSize()
.clip(RectangleShape)
) {
// LAYER 1: CPU-rendered metaball shapes
if (showMetaballLayer) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawIntoCanvas { canvas ->
val nativeCanvas = canvas.nativeCanvas
val optimizedOffsetX = (screenWidthPx - optimizedW) / 2f
// Clear offscreen bitmap
offscreenBitmap.eraseColor(0)
// Draw shapes into downscaled bitmap
offscreenCanvas.save()
offscreenCanvas.scale(
offscreenBitmap.width.toFloat() / optimizedW,
offscreenBitmap.height.toFloat() / optimizedH
)
offscreenCanvas.translate(-optimizedOffsetX, 0f)
// Draw target (notch or black bar)
if (showConnector) {
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) {
val rad = min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f
offscreenCanvas.drawCircle(
notchInfo.bounds.centerX(),
notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f,
rad, blackPaint
)
} else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) {
offscreenCanvas.drawPath(notchInfo.path, blackPaint)
} else if (hasRealNotch && notchInfo != null) {
val bounds = notchInfo.bounds
val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f
offscreenCanvas.drawRoundRect(bounds, rad, rad, blackPaint)
} else {
// No notch: draw black bar
offscreenCanvas.drawRect(0f, 0f, screenWidthPx, blackBarHeightPx, blackPaint)
}
}
// Draw avatar shape
if (avatarState.showBlob) {
val cx = avatarState.centerX
val cy = avatarState.centerY
val r = avatarState.radius
val cornerR = avatarState.cornerRadius
if (cornerR >= r * 0.95f) {
offscreenCanvas.drawCircle(cx, cy, r, blackPaint)
} else {
rectTmp.set(cx - r, cy - r, cx + r, cy + r)
offscreenCanvas.drawRoundRect(rectTmp, cornerR, cornerR, blackPaint)
}
}
// Draw metaball connector
if (showConnector && avatarState.showBlob) {
c1.x = notchCenterX
c1.y = notchCenterY
c2.x = avatarState.centerX
c2.y = avatarState.centerY
if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath)) {
offscreenCanvas.drawPath(metaballPath, blackPaint)
}
}
offscreenCanvas.restore()
// Apply stack blur on CPU
val blurRadius = (ProfileMetaballConstants.BLUR_RADIUS * 2 / scaleConst).toInt().coerceAtLeast(1)
stackBlurBitmapInPlace(offscreenBitmap, blurRadius)
// Draw blurred bitmap with color matrix filter (alpha threshold)
nativeCanvas.save()
nativeCanvas.translate(optimizedOffsetX, 0f)
nativeCanvas.scale(
optimizedW.toFloat() / offscreenBitmap.width,
optimizedH.toFloat() / offscreenBitmap.height
)
nativeCanvas.drawBitmap(offscreenBitmap, 0f, 0f, filterPaint)
nativeCanvas.restore()
}
}
}
// LAYER 2: Avatar content (same as GPU path)
if (avatarState.showBlob) {
val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
val baseSizePx = with(density) { baseSizeDp.toPx() }
val baseScale = (avatarState.radius * 2f) / baseSizePx
val targetExpansionScale = screenWidthPx / baseSizePx
val uniformScale = if (expansionProgress > 0f) {
lerpFloat(baseScale, targetExpansionScale, expansionProgress)
} else {
baseScale
}.coerceAtLeast(0.01f)
val avatarCenterX = avatarState.centerX
val avatarCenterY = avatarState.centerY
val targetCenterX = screenWidthPx / 2f
val targetCenterY = headerHeightPx / 2f
val currentCenterX = if (expansionProgress > 0f) lerpFloat(avatarCenterX, targetCenterX, expansionProgress) else avatarCenterX
val currentCenterY = if (expansionProgress > 0f) lerpFloat(avatarCenterY, targetCenterY, expansionProgress) else avatarCenterY
val cornerRadiusPx: Float = if (expansionProgress > 0f) {
lerpFloat(baseSizePx / 2f, 0f, expansionProgress)
} else {
(avatarState.cornerRadius / baseScale).coerceAtMost(baseSizePx / 2f)
}
val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() }
val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() }
val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() }
Box(
modifier = Modifier
.offset(x = offsetX, y = offsetY)
.width(baseSizeDp)
.height(baseSizeDp)
.graphicsLayer {
scaleX = uniformScale
scaleY = uniformScale
alpha = avatarState.opacity
}
.clip(RoundedCornerShape(cornerRadiusDp)),
contentAlignment = Alignment.Center,
content = avatarContent
)
}
}
}
/**
* DEBUG: Temporary toggle to force a specific rendering path.
* Set forceMode to test different paths on your device:
* - null: auto-detect (default production behavior)
* - "gpu": force GPU path (requires API 31+)
* - "cpu": force CPU bitmap path
* - "compat": force compat/noop path
*
* Set forceNoNotch = true to simulate no-notch device (black bar fallback).
*
* TODO: Remove before release!
*/
object MetaballDebug {
var forceMode: String? = null // "gpu", "cpu", "compat", or null
var forceNoNotch: Boolean = false // true = pretend no notch exists
}
/**
* DEBUG: Floating panel with buttons to switch metaball rendering path.
* Place inside a Box (e.g. profile header) — it aligns to bottom-center.
* TODO: Remove before release!
*/
@Composable
fun MetaballDebugPanel(modifier: Modifier = Modifier) {
var currentMode by remember { mutableStateOf(MetaballDebug.forceMode) }
var noNotch by remember { mutableStateOf(MetaballDebug.forceNoNotch) }
val context = LocalContext.current
val perfClass = remember { DevicePerformanceClass.get(context) }
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.background(
ComposeColor.Black.copy(alpha = 0.75f),
RoundedCornerShape(12.dp)
)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Title
Text(
text = "Metaball Debug | API ${Build.VERSION.SDK_INT} | $perfClass",
color = ComposeColor.White,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
// Mode buttons row
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
val modes = listOf(null to "Auto", "gpu" to "GPU", "cpu" to "CPU", "compat" to "Compat")
modes.forEach { (mode, label) ->
val isSelected = currentMode == mode
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(
if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.15f)
)
.border(
width = 1.dp,
color = if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.3f),
shape = RoundedCornerShape(8.dp)
)
.clickable {
MetaballDebug.forceMode = mode
currentMode = mode
}
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
color = ComposeColor.White,
fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
}
}
}
// No-notch toggle
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Force no-notch (black bar)",
color = ComposeColor.White,
fontSize = 12.sp
)
Switch(
checked = noNotch,
onCheckedChange = {
MetaballDebug.forceNoNotch = it
noNotch = it
},
colors = SwitchDefaults.colors(
checkedThumbColor = ComposeColor(0xFF4CAF50),
checkedTrackColor = ComposeColor(0xFF4CAF50).copy(alpha = 0.5f)
)
)
}
// Current active path info
val activePath = when (currentMode) {
"gpu" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "GPU (forced)" else "GPU needs API 31!"
"cpu" -> "CPU (forced)"
"compat" -> "Compat (forced)"
else -> when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && perfClass >= PerformanceClass.AVERAGE -> "GPU (auto)"
perfClass >= PerformanceClass.HIGH -> "CPU (auto)"
else -> "Compat (auto)"
}
}
Text(
text = "Active: $activePath" + if (noNotch) " + no-notch" else "",
color = ComposeColor(0xFF4CAF50),
fontSize = 11.sp,
fontWeight = FontWeight.Medium
)
}
}
/**
* Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView:
* 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter
* 2. CPU path (any Android, high performance): Bitmap blur + ColorMatrixColorFilter
* 3. Noop path (low-end devices): Simple scale/opacity animation
*/
@Composable
fun ProfileMetaballEffect(
@@ -817,27 +1250,76 @@ fun ProfileMetaballEffect(
modifier: Modifier = Modifier,
avatarContent: @Composable BoxScope.() -> Unit = {},
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ProfileMetaballOverlay(
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar,
avatarColor = avatarColor,
modifier = modifier,
avatarContent = avatarContent
)
} else {
ProfileMetaballOverlayCompat(
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar,
avatarColor = avatarColor,
modifier = modifier,
avatarContent = avatarContent
)
val context = LocalContext.current
val performanceClass = remember { DevicePerformanceClass.get(context) }
// Debug: log which path is selected
val selectedPath = when (MetaballDebug.forceMode) {
"gpu" -> "GPU (forced)"
"cpu" -> "CPU (forced)"
"compat" -> "Compat (forced)"
else -> when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && performanceClass >= PerformanceClass.AVERAGE -> "GPU (auto)"
performanceClass >= PerformanceClass.HIGH -> "CPU (auto)"
else -> "Compat (auto)"
}
}
LaunchedEffect(selectedPath) {
Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}")
}
// Resolve actual mode
val useGpu = when (MetaballDebug.forceMode) {
"gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31
"cpu" -> false
"compat" -> false
else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && performanceClass >= PerformanceClass.AVERAGE
}
val useCpu = when (MetaballDebug.forceMode) {
"gpu" -> false
"cpu" -> true
"compat" -> false
else -> !useGpu && performanceClass >= PerformanceClass.HIGH
}
when {
useGpu -> {
val factorMult = if (performanceClass == PerformanceClass.HIGH) 1f else 1.5f
ProfileMetaballOverlay(
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar,
avatarColor = avatarColor,
factorMult = factorMult,
modifier = modifier,
avatarContent = avatarContent
)
}
useCpu -> {
ProfileMetaballOverlayCpu(
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar,
avatarColor = avatarColor,
modifier = modifier,
avatarContent = avatarContent
)
}
else -> {
ProfileMetaballOverlayCompat(
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar,
avatarColor = avatarColor,
modifier = modifier,
avatarContent = avatarContent
)
}
}
}

View File

@@ -55,6 +55,7 @@ import com.rosetta.messenger.biometric.BiometricAvailability
import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.ImageCropHelper