Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import androidx.compose.ui.unit.dp
import com.flipcash.core.R
import com.flipcash.services.models.SocialAccount
import com.flipcash.services.models.chat.MediaItem
import com.flipcash.services.models.chat.MediaItemRendition
import com.flipcash.shared.common.ui.ContactAvatar
import com.getcode.theme.CodeTheme
import com.getcode.ui.components.SwipeAction
Expand Down Expand Up @@ -278,7 +277,7 @@ private fun ProfileHeader(
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
) {
ContactAvatar(
photoUri = profilePicture?.url(preferred = MediaItemRendition.Role.DISPLAY),
image = profilePicture,
displayName = displayName.orEmpty(),
modifier = Modifier
.size(96.dp)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package com.flipcash.shared.common.ui

import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.withSign

/**
* Decoder for [BlurHash](https://blurha.sh) strings — the compact, blurred image preview that
* ships in a media item's [com.flipcash.services.models.chat.ImageMetadata]. Decode it into a tiny
* bitmap and let the image layer scale it up as an instant placeholder while the real (larger)
* image downloads.
*
* Ported from the public-domain reference implementation. A hash only encodes a handful of DCT
* components, so decode at a low resolution (a couple dozen pixels) — anything larger just wastes
* cycles for no visible gain.
*/
object BlurHash {

/**
* Decodes [blurHash] into an [width] × [height] [Bitmap], or null if the string is malformed.
* [punch] adjusts contrast (1f = as encoded).
*/
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f): Bitmap? {
if (blurHash == null || blurHash.length < 6 || width <= 0 || height <= 0) return null

val sizeFlag = decode83(blurHash, 0, 1) ?: return null
val numCompX = sizeFlag % 9 + 1
val numCompY = sizeFlag / 9 + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) return null

val maxAc = ((decode83(blurHash, 1, 2) ?: return null) + 1) / 166f
val colors = Array(numCompX * numCompY) { i ->
if (i == 0) {
decodeDc(decode83(blurHash, 2, 6) ?: return null)
} else {
val from = 4 + i * 2
decodeAc(decode83(blurHash, from, from + 2) ?: return null, maxAc * punch)
}
}
return composeBitmap(width, height, numCompX, numCompY, colors)
}

private fun decode83(str: String, from: Int, to: Int): Int? {
var result = 0
for (i in from until to) {
val index = CHARS.indexOf(str[i])
if (index < 0) return null
result = result * 83 + index
}
return result
}

private fun decodeDc(colorEnc: Int): FloatArray = floatArrayOf(
srgbToLinear(colorEnc shr 16 and 255),
srgbToLinear(colorEnc shr 8 and 255),
srgbToLinear(colorEnc and 255),
)

private fun decodeAc(value: Int, maxAc: Float): FloatArray = floatArrayOf(
signPow((value / (19 * 19) - 9) / 9f) * maxAc,
signPow((value / 19 % 19 - 9) / 9f) * maxAc,
signPow((value % 19 - 9) / 9f) * maxAc,
)

/** sign(value) * value² — the reference impl's quantisation curve. */
private fun signPow(value: Float): Float = value.pow(2f).withSign(value)

private fun srgbToLinear(colorEnc: Int): Float {
val v = colorEnc / 255f
return if (v <= 0.04045f) v / 12.92f else ((v + 0.055f) / 1.055f).pow(2.4f)
}

private fun linearToSrgb(value: Float): Int {
val v = value.coerceIn(0f, 1f)
val srgb = if (v <= 0.0031308f) v * 12.92f else 1.055f * v.pow(1f / 2.4f) - 0.055f
return (srgb * 255f + 0.5f).toInt()
}

private fun composeBitmap(
width: Int,
height: Int,
numCompX: Int,
numCompY: Int,
colors: Array<FloatArray>,
): Bitmap {
// Precompute the cosine basis for each axis so the inner pixel loop is just multiplies.
val cosX = FloatArray(width * numCompX)
for (x in 0 until width) {
for (i in 0 until numCompX) {
cosX[x * numCompX + i] = cos(PI * x * i / width).toFloat()
}
}
val cosY = FloatArray(height * numCompY)
for (y in 0 until height) {
for (j in 0 until numCompY) {
cosY[y * numCompY + j] = cos(PI * y * j / height).toFloat()
}
}

val pixels = IntArray(width * height)
for (y in 0 until height) {
for (x in 0 until width) {
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until numCompY) {
val cy = cosY[y * numCompY + j]
for (i in 0 until numCompX) {
val basis = cosX[x * numCompX + i] * cy
val color = colors[j * numCompX + i]
r += color[0] * basis
g += color[1] * basis
b += color[2] * basis
}
}
pixels[y * width + x] =
(0xFF shl 24) or (linearToSrgb(r) shl 16) or (linearToSrgb(g) shl 8) or linearToSrgb(b)
}
}
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
}

private const val CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#\$%*+,-.:;=?@[]^_{|}~"
}

/**
* Remembers a [Painter] that draws [blurHash]'s decoded preview, or null when the hash is absent or
* malformed. Decodes at a deliberately tiny [width]/[height]; callers scale it to fit.
*/
@Composable
fun rememberBlurHashPainter(
blurHash: String?,
width: Int = 24,
height: Int = 24,
): Painter? = remember(blurHash, width, height) {
val bitmap = BlurHash.decode(blurHash, width, height) ?: return@remember null
BitmapPainter(bitmap.asImageBitmap())
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.min
import androidx.core.net.toUri
import coil3.asImage
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.request.placeholder
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.MediaItemRendition
import com.flipcash.services.models.chat.MediaItem
import com.getcode.theme.CodeTheme
import com.getcode.ui.core.addIf

Expand Down Expand Up @@ -102,23 +104,83 @@ fun ContactAvatar(
@Composable
fun ContactAvatar(
userProfile: UserProfile,
imageRole: MediaItemRendition.Role = MediaItemRendition.Role.THUMBNAIL,
modifier: Modifier = Modifier,
) {
ProfileAvatar(
image = userProfile.profilePicture,
modifier = modifier,
fallback = { UnknownContactAvatar(includeBorder = true) },
)
}

/**
* Renders a server-side profile picture ([MediaItem]) — the tips list, chat header, info card, etc.
* Prefer this over the raw `photoUri` overload for anything backed by a [MediaItem]: it picks the
* rendition that matches the avatar's measured pixel size (the server ships several thumbnail /
* display sizes) so the image is never grainy or over-fetched, and bridges the load with the
* item's BlurHash plus any already-cached smaller rendition. Falls back to [displayName]'s
* initials when there's no picture.
*/
@Composable
fun ContactAvatar(
image: MediaItem?,
displayName: String,
modifier: Modifier = Modifier,
) {
ProfileAvatar(
image = image,
modifier = modifier,
fallback = { InitialsText(displayName) },
)
}

@Composable
private fun ProfileAvatar(
image: MediaItem?,
modifier: Modifier,
fallback: @Composable BoxWithConstraintsScope.() -> Unit,
) {
BoxWithConstraints(
modifier = modifier.background(
Brush.linearGradient(CodeTheme.colors.contactAvatar.colors)
)
) {
val photoUri = userProfile.profilePicture?.url(imageRole)
if (photoUri != null) {
// Pick the rendition by the avatar's actual pixel size — the longest bounded side of the
// measured constraints (unbounded → request the largest, so it's never under-sized).
val targetPx = remember(constraints) {
val w = if (constraints.hasBoundedWidth) constraints.maxWidth else 0
val h = if (constraints.hasBoundedHeight) constraints.maxHeight else 0
maxOf(w, h).takeIf { it > 0 } ?: Int.MAX_VALUE
}
val photoUri = remember(image, targetPx) { image?.urlForSize(targetPx) }
if (image != null && photoUri != null) {
var isError by rememberSaveable(photoUri) { mutableStateOf(false) }
if (!isError) {
val context = LocalContext.current
val request = remember(photoUri) {
// Two progressively better placeholders bridge the load so we never flash a blank
// gradient while the correctly-sized rendition downloads:
// 1. the BlurHash — an instant, self-contained blurred preview, and
// 2. the next-smaller rendition — if another surface already cached it (e.g. the
// list loaded the 160 this 320 avatar sits above), Coil shows it immediately
// (see placeholderMemoryCacheKey) and upgrades in place.
val blurHash = remember(image) {
BlurHash.decode(image.blurhash(), width = 24, height = 24)?.asImage()
}
val previewKey = remember(image, targetPx, photoUri) {
image.renditionBelow(targetPx)?.blob?.downloadUrl?.takeIf { it != photoUri }
}
val request = remember(photoUri, previewKey, blurHash) {
ImageRequest.Builder(context)
.crossfade(true)
.data(photoUri.toUri())
// Key on the download URL alone (not size) so every avatar load of this
// rendition shares one cache entry — which is what lets a smaller rendition
// reliably resolve via placeholderMemoryCacheKey across surfaces.
.memoryCacheKey(photoUri)
.apply {
blurHash?.let { placeholder(it) }
previewKey?.let { placeholderMemoryCacheKey(it) }
}
.build()
}
AsyncImage(
Expand All @@ -133,10 +195,10 @@ fun ContactAvatar(
)
}
if (isError) {
UnknownContactAvatar(includeBorder = true)
fallback()
}
} else {
UnknownContactAvatar(includeBorder = true)
fallback()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.flipcash.shared.common.ui

import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull

@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE)
class BlurHashTest {

// A valid 4x3-component hash from the BlurHash reference test vectors.
private val validHash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj"

@Test
fun `decodes a valid hash to a bitmap of the requested size`() {
val bitmap = BlurHash.decode(validHash, width = 32, height = 24)
assertNotNull(bitmap)
assertEquals(32, bitmap.width)
assertEquals(24, bitmap.height)
}

@Test
fun `returns null for null or blank hashes`() {
assertNull(BlurHash.decode(null, 16, 16))
assertNull(BlurHash.decode("", 16, 16))
}

@Test
fun `returns null for a hash whose declared component count doesn't match its length`() {
// Truncated hash: the size flag promises more components than the string carries.
assertNull(BlurHash.decode(validHash.substring(0, validHash.length - 4), 16, 16))
}

@Test
fun `returns null for non-positive dimensions`() {
assertNull(BlurHash.decode(validHash, width = 0, height = 16))
assertNull(BlurHash.decode(validHash, width = 16, height = -1))
}

@Test
fun `returns null for hashes containing characters outside the base-83 alphabet`() {
// 'é' is not part of the BlurHash alphabet.
val invalid = "L" + "é".repeat(validHash.length - 1)
assertNull(BlurHash.decode(invalid, 16, 16))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import com.flipcash.services.controllers.PushController
import com.flipcash.services.models.SocialAccount
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.MediaItemRendition
import com.flipcash.services.models.NavigationTrigger
import com.flipcash.services.models.NotificationCategory
import com.flipcash.services.models.NotificationPayload
Expand Down Expand Up @@ -250,9 +249,14 @@ class NotificationService : FirebaseMessagingService(),
// Device-contact photo (local, synchronous) first; otherwise the profile
// picture URL loaded through the app's shared Coil loader (cache-first,
// network-bounded). Works for CONTACT_DM and TIP_DM alike.
//
// Size the rendition to the platform's large-icon dimension (density-scaled) rather than
// grabbing the smallest THUMBNAIL — the server ships several thumbnail/display sizes and
// the tiny 32px one looks grainy on the notification's person icon.
val avatarPx = resources.getDimensionPixelSize(android.R.dimen.notification_large_icon_width)
val avatar = e164?.let { resolveContactPhoto(it) }
?: member?.userProfile?.profilePicture
?.url(MediaItemRendition.Role.THUMBNAIL)
?.urlForSize(avatarPx)
?.let { loadRemoteAvatar(it) }

trace(
Expand Down
Loading
Loading