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