Skip to content

Commit da62092

Browse files
feat(chat): use Blurhash for chat messages
Resolves: #6071 AI-assistant: Claude Code v2.1.141 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent 6ea0469 commit da62092

3 files changed

Lines changed: 199 additions & 11 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*
7+
* BlurHash decode algorithm adapted from https://github.com/woltapp/blurhash (Kotlin implementation).
8+
* Original work copyright (c) 2019 Wolt Enterprises; used under the MIT License.
9+
* See https://github.com/woltapp/blurhash/blob/master/License.txt
10+
*/
11+
12+
package com.nextcloud.talk.chat.data.model
13+
14+
import android.graphics.Bitmap
15+
import android.graphics.Color
16+
import kotlin.math.PI
17+
import kotlin.math.cos
18+
import kotlin.math.pow
19+
import kotlin.math.withSign
20+
21+
private const val BLURHASH_CHARS =
22+
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#\$%*+,-.:;=?@[]^_{|}~"
23+
private const val BLURHASH_DECODE_WIDTH = 32
24+
25+
// BlurHash protocol constants (https://github.com/woltapp/blurhash/blob/master/Algorithm.md)
26+
private const val BLURHASH_MIN_LENGTH = 6
27+
private const val BLURHASH_BASE = 83
28+
private const val BLURHASH_MAX_COMPONENTS = 9
29+
private const val BLURHASH_HEADER_SIZE = 4
30+
private const val BLURHASH_DC_END = 6
31+
private const val BLURHASH_AC_QUANT_RANGE = 166f
32+
private const val BLURHASH_RGB_CHANNELS = 3
33+
private const val BLURHASH_AC_QUANT_STEPS = 19
34+
private const val BLURHASH_AC_QUANT_BIAS = 9
35+
private const val BLURHASH_AC_QUANT_BIAS_F = 9f
36+
37+
// sRGB IEC 61966-2-1 transfer function constants
38+
private const val COLOR_RED_SHIFT = 16
39+
private const val COLOR_GREEN_SHIFT = 8
40+
private const val COLOR_BYTE_MASK = 0xFF
41+
private const val SRGB_MAX_BYTE = 255f
42+
private const val SRGB_LINEAR_THRESHOLD = 0.04045f
43+
private const val SRGB_LINEAR_SCALE = 12.92f
44+
private const val SRGB_GAMMA_OFFSET = 0.055f
45+
private const val SRGB_GAMMA_SCALE = 1.055f
46+
private const val SRGB_GAMMA = 2.4f
47+
private const val SRGB_LINEAR_THRESHOLD_INV = 0.0031308f
48+
private const val SRGB_ROUND = 0.5f
49+
50+
internal object BlurHashDecoder {
51+
52+
fun decode(hash: String?, width: Int, height: Int): Bitmap? {
53+
if (hash.isNullOrBlank() || hash.length < BLURHASH_MIN_LENGTH) return null
54+
55+
val numCompEnc = decode83(hash, 0, 1)
56+
val numCompX = (numCompEnc % BLURHASH_MAX_COMPONENTS) + 1
57+
val numCompY = (numCompEnc / BLURHASH_MAX_COMPONENTS) + 1
58+
59+
return if (hash.length != BLURHASH_HEADER_SIZE + 2 * numCompX * numCompY) {
60+
null
61+
} else {
62+
val maxAc = (decode83(hash, 1, 2) + 1) / BLURHASH_AC_QUANT_RANGE
63+
val colors = Array(numCompX * numCompY) { FloatArray(BLURHASH_RGB_CHANNELS) }
64+
decodeDc(decode83(hash, 2, BLURHASH_DC_END), colors[0])
65+
for (i in 1 until colors.size) {
66+
val from = BLURHASH_HEADER_SIZE + i * 2
67+
decodeAc(decode83(hash, from, from + 2), maxAc, colors[i])
68+
}
69+
runCatching { composeBitmap(width, height, numCompX, numCompY, colors) }.getOrNull()
70+
}
71+
}
72+
73+
private fun decode83(str: String, from: Int, to: Int): Int {
74+
var result = 0
75+
for (i in from until to) {
76+
val idx = BLURHASH_CHARS.indexOf(str[i])
77+
if (idx >= 0) result = result * BLURHASH_BASE + idx
78+
}
79+
return result
80+
}
81+
82+
private fun decodeDc(value: Int, out: FloatArray) {
83+
out[0] = srgbToLinear((value shr COLOR_RED_SHIFT) and COLOR_BYTE_MASK)
84+
out[1] = srgbToLinear((value shr COLOR_GREEN_SHIFT) and COLOR_BYTE_MASK)
85+
out[2] = srgbToLinear(value and COLOR_BYTE_MASK)
86+
}
87+
88+
private fun decodeAc(value: Int, maxAc: Float, out: FloatArray) {
89+
val steps = BLURHASH_AC_QUANT_STEPS
90+
val bias = BLURHASH_AC_QUANT_BIAS
91+
val biasF = BLURHASH_AC_QUANT_BIAS_F
92+
out[0] = signedPow2(((value / (steps * steps)) - bias) / biasF) * maxAc
93+
out[1] = signedPow2(((value / steps) % steps - bias) / biasF) * maxAc
94+
out[2] = signedPow2((value % steps - bias) / biasF) * maxAc
95+
}
96+
97+
private fun srgbToLinear(v: Int): Float {
98+
val f = v / SRGB_MAX_BYTE
99+
return if (f <= SRGB_LINEAR_THRESHOLD) {
100+
f / SRGB_LINEAR_SCALE
101+
} else {
102+
((f + SRGB_GAMMA_OFFSET) / SRGB_GAMMA_SCALE).pow(SRGB_GAMMA)
103+
}
104+
}
105+
106+
private fun linearToSrgb(v: Float): Int {
107+
val c = v.coerceIn(0f, 1f)
108+
return if (c <= SRGB_LINEAR_THRESHOLD_INV) {
109+
(c * SRGB_LINEAR_SCALE * SRGB_MAX_BYTE + SRGB_ROUND).toInt()
110+
} else {
111+
((SRGB_GAMMA_SCALE * c.pow(1f / SRGB_GAMMA) - SRGB_GAMMA_OFFSET) * SRGB_MAX_BYTE + SRGB_ROUND).toInt()
112+
}
113+
}
114+
115+
private fun signedPow2(v: Float) = (v * v).withSign(v)
116+
117+
private fun composeBitmap(
118+
width: Int,
119+
height: Int,
120+
numCompX: Int,
121+
numCompY: Int,
122+
colors: Array<FloatArray>
123+
): Bitmap {
124+
fun computePixel(x: Int, y: Int): Int {
125+
var r = 0f
126+
var g = 0f
127+
var b = 0f
128+
for (cy in 0 until numCompY) {
129+
for (cx in 0 until numCompX) {
130+
val basis = (cos(PI * x * cx / width) * cos(PI * y * cy / height)).toFloat()
131+
val c = colors[cy * numCompX + cx]
132+
r += c[0] * basis
133+
g += c[1] * basis
134+
b += c[2] * basis
135+
}
136+
}
137+
return Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
138+
}
139+
140+
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
141+
for (y in 0 until height) {
142+
for (x in 0 until width) {
143+
bitmap.setPixel(x, y, computePixel(x, y))
144+
}
145+
}
146+
return bitmap
147+
}
148+
}
149+
150+
/**
151+
* Decodes [hash] into a small Bitmap sized to preserve the original aspect ratio.
152+
* Always decodes at [BLURHASH_DECODE_WIDTH] px wide — Coil scales it to fill the view.
153+
* Returns null when [hash] is absent, blank, or malformed.
154+
*/
155+
fun decodeBlurhashPlaceholder(hash: String?, imageWidth: Int?, imageHeight: Int?): Bitmap? {
156+
if (hash.isNullOrBlank()) return null
157+
val decodeHeight = if (imageWidth != null && imageHeight != null && imageWidth > 0) {
158+
(BLURHASH_DECODE_WIDTH * imageHeight / imageWidth).coerceAtLeast(1)
159+
} else {
160+
BLURHASH_DECODE_WIDTH
161+
}
162+
return BlurHashDecoder.decode(hash, BLURHASH_DECODE_WIDTH, decodeHeight)
163+
}

app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ sealed interface MessageTypeContent {
7171
val previewUrl: String?,
7272
val drawableResourceId: Int,
7373
val mimeType: String,
74-
val animateGif: Boolean = false
74+
val animateGif: Boolean = false,
75+
val blurhash: String? = null,
76+
val width: Int? = null,
77+
val height: Int? = null
7578
) : MessageTypeContent
7679

7780
data class Geolocation(val id: String, val name: String, val lat: Double, val lon: Double) : MessageTypeContent
@@ -278,7 +281,10 @@ fun getMediaContent(user: User, message: ChatMessage): MessageTypeContent.Media
278281
previewUrl = previewUrl,
279282
drawableResourceId = drawableResourceId,
280283
mimeType = mimetype,
281-
animateGif = animateGif
284+
animateGif = animateGif,
285+
blurhash = message.fileParameters.blurhash,
286+
width = message.fileParameters.width,
287+
height = message.fileParameters.height
282288
)
283289
}
284290

app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ package com.nextcloud.talk.ui.chat
1010
import androidx.compose.foundation.clickable
1111
import androidx.compose.foundation.layout.Box
1212
import androidx.compose.foundation.layout.Column
13+
import androidx.compose.foundation.layout.aspectRatio
1314
import androidx.compose.foundation.layout.fillMaxWidth
1415
import androidx.compose.foundation.layout.padding
1516
import androidx.compose.foundation.layout.size
@@ -22,6 +23,8 @@ import androidx.compose.ui.Alignment
2223
import androidx.compose.ui.Modifier
2324
import androidx.compose.ui.draw.clip
2425
import androidx.compose.ui.graphics.Color
26+
import androidx.compose.ui.graphics.asImageBitmap
27+
import androidx.compose.ui.graphics.painter.BitmapPainter
2528
import androidx.compose.ui.layout.ContentScale
2629
import androidx.compose.ui.platform.LocalContext
2730
import androidx.compose.ui.res.painterResource
@@ -30,6 +33,7 @@ import androidx.compose.ui.unit.dp
3033
import coil.compose.AsyncImage
3134
import com.nextcloud.talk.R
3235
import com.nextcloud.talk.chat.data.model.FileParameters
36+
import com.nextcloud.talk.chat.data.model.decodeBlurhashPlaceholder
3337
import com.nextcloud.talk.chat.ui.model.ChatMessageUi
3438
import com.nextcloud.talk.chat.ui.model.MessageTypeContent
3539
import com.nextcloud.talk.contacts.load
@@ -93,21 +97,36 @@ fun MediaMessage(
9397
(isGif && !typeContent.animateGif)
9498
)
9599

96-
Box(modifier = Modifier.fillMaxWidth()) {
97-
val loadedImage = remember(typeContent.previewUrl) {
98-
load(
99-
imageUri = typeContent.previewUrl,
100-
context = context,
101-
errorPlaceholderImage = typeContent.drawableResourceId,
102-
animated = typeContent.animateGif
103-
)
104-
}
100+
val blurhashPainter = remember(typeContent.blurhash, typeContent.width, typeContent.height) {
101+
decodeBlurhashPlaceholder(typeContent.blurhash, typeContent.width, typeContent.height)
102+
?.asImageBitmap()
103+
?.let { BitmapPainter(it) }
104+
}
105+
val aspectRatio = remember(typeContent.width, typeContent.height) {
106+
val w = typeContent.width
107+
val h = typeContent.height
108+
if (w != null && h != null && w > 0 && h > 0) w.toFloat() / h else null
109+
}
110+
val loadedImage = remember(typeContent.previewUrl) {
111+
load(
112+
imageUri = typeContent.previewUrl,
113+
context = context,
114+
errorPlaceholderImage = typeContent.drawableResourceId,
115+
animated = typeContent.animateGif
116+
)
117+
}
118+
val fallbackPainter = painterResource(typeContent.drawableResourceId)
105119

120+
Box(modifier = Modifier.fillMaxWidth()) {
106121
AsyncImage(
107122
model = loadedImage,
108123
contentDescription = stringResource(R.string.media_message_content_description),
124+
placeholder = blurhashPainter ?: fallbackPainter,
125+
error = blurhashPainter ?: fallbackPainter,
126+
fallback = blurhashPainter ?: fallbackPainter,
109127
modifier = Modifier
110128
.fillMaxWidth()
129+
.then(if (aspectRatio != null) Modifier.aspectRatio(aspectRatio) else Modifier)
111130
.padding(mediaInset)
112131
.clip(mediaShape)
113132
.clickable { onImageClick(message.id) },

0 commit comments

Comments
 (0)