Skip to content

Commit bc6a703

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 bc6a703

3 files changed

Lines changed: 161 additions & 11 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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+
internal object BlurHashDecoder {
26+
27+
fun decode(hash: String?, width: Int, height: Int): Bitmap? {
28+
if (hash.isNullOrBlank() || hash.length < 6) return null
29+
30+
val numCompEnc = decode83(hash, 0, 1)
31+
val numCompX = (numCompEnc % 9) + 1
32+
val numCompY = (numCompEnc / 9) + 1
33+
if (hash.length != 4 + 2 * numCompX * numCompY) return null
34+
35+
val maxAc = (decode83(hash, 1, 2) + 1) / 166f
36+
val colors = Array(numCompX * numCompY) { FloatArray(3) }
37+
decodeDc(decode83(hash, 2, 6), colors[0])
38+
for (i in 1 until colors.size) {
39+
val from = 4 + i * 2
40+
decodeAc(decode83(hash, from, from + 2), maxAc, colors[i])
41+
}
42+
43+
return runCatching { composeBitmap(width, height, numCompX, numCompY, colors) }.getOrNull()
44+
}
45+
46+
private fun decode83(str: String, from: Int, to: Int): Int {
47+
var result = 0
48+
for (i in from until to) {
49+
val idx = BLURHASH_CHARS.indexOf(str[i])
50+
if (idx >= 0) result = result * 83 + idx
51+
}
52+
return result
53+
}
54+
55+
private fun decodeDc(value: Int, out: FloatArray) {
56+
out[0] = srgbToLinear((value shr 16) and 0xFF)
57+
out[1] = srgbToLinear((value shr 8) and 0xFF)
58+
out[2] = srgbToLinear(value and 0xFF)
59+
}
60+
61+
private fun decodeAc(value: Int, maxAc: Float, out: FloatArray) {
62+
out[0] = signedPow2(((value / (19 * 19)) - 9) / 9f) * maxAc
63+
out[1] = signedPow2(((value / 19) % 19 - 9) / 9f) * maxAc
64+
out[2] = signedPow2((value % 19 - 9) / 9f) * maxAc
65+
}
66+
67+
private fun srgbToLinear(v: Int): Float {
68+
val f = v / 255f
69+
return if (f <= 0.04045f) f / 12.92f else ((f + 0.055f) / 1.055f).pow(2.4f)
70+
}
71+
72+
private fun linearToSrgb(v: Float): Int {
73+
val c = v.coerceIn(0f, 1f)
74+
return if (c <= 0.0031308f) {
75+
(c * 12.92f * 255f + 0.5f).toInt()
76+
} else {
77+
((1.055f * c.pow(1f / 2.4f) - 0.055f) * 255f + 0.5f).toInt()
78+
}
79+
}
80+
81+
private fun signedPow2(v: Float) = (v * v).withSign(v)
82+
83+
private fun composeBitmap(
84+
width: Int,
85+
height: Int,
86+
numCompX: Int,
87+
numCompY: Int,
88+
colors: Array<FloatArray>
89+
): Bitmap {
90+
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
91+
for (y in 0 until height) {
92+
for (x in 0 until width) {
93+
var r = 0f
94+
var g = 0f
95+
var b = 0f
96+
for (cy in 0 until numCompY) {
97+
for (cx in 0 until numCompX) {
98+
val basis = (cos(PI * x * cx / width) * cos(PI * y * cy / height)).toFloat()
99+
val c = colors[cy * numCompX + cx]
100+
r += c[0] * basis
101+
g += c[1] * basis
102+
b += c[2] * basis
103+
}
104+
}
105+
bitmap.setPixel(x, y, Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)))
106+
}
107+
}
108+
return bitmap
109+
}
110+
}
111+
112+
/**
113+
* Decodes [hash] into a small Bitmap sized to preserve the original aspect ratio.
114+
* Always decodes at [BLURHASH_DECODE_WIDTH] px wide — Coil scales it to fill the view.
115+
* Returns null when [hash] is absent, blank, or malformed.
116+
*/
117+
fun decodeBlurhashPlaceholder(hash: String?, imageWidth: Int?, imageHeight: Int?): Bitmap? {
118+
if (hash.isNullOrBlank()) return null
119+
val decodeHeight = if (imageWidth != null && imageHeight != null && imageWidth > 0) {
120+
(BLURHASH_DECODE_WIDTH * imageHeight / imageWidth).coerceAtLeast(1)
121+
} else {
122+
BLURHASH_DECODE_WIDTH
123+
}
124+
return BlurHashDecoder.decode(hash, BLURHASH_DECODE_WIDTH, decodeHeight)
125+
}

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)