From 8b5db46b3aa1296c1f31801a8b2e67422dfc78d1 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 7 Feb 2026 05:32:39 +0500 Subject: [PATCH] feat: implement CPU-based metaball rendering and performance classification --- .../ui/components/metaball/CpuBlurUtils.kt | 204 ++++++ .../metaball/DevicePerformanceClass.kt | 92 +++ .../ui/components/metaball/MetaballEffect.kt | 74 +- .../metaball/ProfileMetaballOverlay.kt | 678 +++++++++++++++--- .../messenger/ui/settings/ProfileScreen.kt | 1 + 5 files changed, 902 insertions(+), 147 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/components/metaball/CpuBlurUtils.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/components/metaball/DevicePerformanceClass.kt diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/CpuBlurUtils.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/CpuBlurUtils.kt new file mode 100644 index 0000000..358de1f --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/CpuBlurUtils.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/DevicePerformanceClass.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/DevicePerformanceClass.kt new file mode 100644 index 0000000..0b1439e --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/DevicePerformanceClass.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballEffect.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballEffect.kt index ead542a..bee2c2c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballEffect.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballEffect.kt @@ -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() } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt index 18fc055..7a3b1a4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt @@ -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 + ) + } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index b1a8a92..edc1d35 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -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