|
| 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 | +} |
0 commit comments