From 77e85a9a444be1675fa8d862720ed570a96d8c0e Mon Sep 17 00:00:00 2001 From: Christophe Beyls Date: Mon, 8 Jul 2024 00:37:45 +0200 Subject: [PATCH 1/3] chore: upgrade Android project and dependencies to new modern defaults --- Kotlin/build.gradle | 12 +++----- Kotlin/demo/build.gradle | 19 ++++++++---- Kotlin/demo/src/main/AndroidManifest.xml | 7 +++-- .../java/com/wolt/blurhashapp/MainActivity.kt | 29 +++++++++---------- Kotlin/gradle.properties | 3 +- .../gradle/wrapper/gradle-wrapper.properties | 4 +-- Kotlin/lib/build.gradle | 21 +++++++++----- Kotlin/lib/src/main/AndroidManifest.xml | 1 - 8 files changed, 51 insertions(+), 45 deletions(-) delete mode 100644 Kotlin/lib/src/main/AndroidManifest.xml diff --git a/Kotlin/build.gradle b/Kotlin/build.gradle index b971d828..3984b156 100644 --- a/Kotlin/build.gradle +++ b/Kotlin/build.gradle @@ -1,15 +1,15 @@ buildscript { - ext.kotlin_version = '1.3.72' + ext.kotlin_version = '2.0.0' repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' + classpath 'com.android.tools.build:gradle:8.5.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } @@ -19,11 +19,7 @@ allprojects { repositories { google() - jcenter() + mavenCentral() } } - -task clean(type: Delete) { - delete rootProject.buildDir -} diff --git a/Kotlin/demo/build.gradle b/Kotlin/demo/build.gradle index 6ccb0378..aeb771a5 100644 --- a/Kotlin/demo/build.gradle +++ b/Kotlin/demo/build.gradle @@ -1,15 +1,15 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' android { - compileSdkVersion 29 + compileSdk 34 + namespace "com.wolt.blurhashapp" defaultConfig { applicationId "com.wolt.blurhash" - minSdkVersion 14 - targetSdkVersion 29 + minSdkVersion 21 + targetSdkVersion 34 versionCode 1 versionName "1.0" } @@ -21,9 +21,16 @@ android { } } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } } dependencies { - implementation project(path: ':lib') - implementation 'androidx.appcompat:appcompat:1.1.0' + implementation project(':lib') + implementation 'androidx.appcompat:appcompat:1.7.0' } diff --git a/Kotlin/demo/src/main/AndroidManifest.xml b/Kotlin/demo/src/main/AndroidManifest.xml index 3be7349e..4dc3b4c6 100644 --- a/Kotlin/demo/src/main/AndroidManifest.xml +++ b/Kotlin/demo/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + - + diff --git a/Kotlin/demo/src/main/java/com/wolt/blurhashapp/MainActivity.kt b/Kotlin/demo/src/main/java/com/wolt/blurhashapp/MainActivity.kt index 3d48edba..b780f7d6 100644 --- a/Kotlin/demo/src/main/java/com/wolt/blurhashapp/MainActivity.kt +++ b/Kotlin/demo/src/main/java/com/wolt/blurhashapp/MainActivity.kt @@ -2,34 +2,33 @@ package com.wolt.blurhashapp import android.graphics.Bitmap import android.os.Bundle -import android.os.SystemClock +import android.view.View +import android.widget.EditText +import android.widget.ImageView +import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import com.wolt.blurhashkt.BlurHashDecoder -import kotlinx.android.synthetic.main.activity_main.* +import kotlin.time.measureTime class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + + val tvDecode: View = findViewById(R.id.tvDecode) + val etInput: EditText = findViewById(R.id.etInput) + val ivResult: ImageView = findViewById(R.id.ivResult) + val ivResultTime: TextView = findViewById(R.id.ivResultTime) + tvDecode.setOnClickListener { - var bitmap: Bitmap? = null - val time = timed { + val bitmap: Bitmap? + val time = measureTime { bitmap = BlurHashDecoder.decode(etInput.text.toString(), 20, 12) } ivResult.setImageBitmap(bitmap) - ivResultTime.text = "Time: $time ms" + ivResultTime.text = "Time: ${time.inWholeMilliseconds} ms" } } } - -/** - * Executes a function and return the time spent in milliseconds. - */ -private inline fun timed(function: () -> Unit): Long { - val start = SystemClock.elapsedRealtime() - function() - return SystemClock.elapsedRealtime() - start -} - diff --git a/Kotlin/gradle.properties b/Kotlin/gradle.properties index 8de50581..58808264 100644 --- a/Kotlin/gradle.properties +++ b/Kotlin/gradle.properties @@ -6,9 +6,8 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -android.enableJetifier=true android.useAndroidX=true -org.gradle.jvmargs=-Xmx1536m +org.gradle.jvmargs=-Xmx2048m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/Kotlin/gradle/wrapper/gradle-wrapper.properties b/Kotlin/gradle/wrapper/gradle-wrapper.properties index 032d0433..2302b4db 100644 --- a/Kotlin/gradle/wrapper/gradle-wrapper.properties +++ b/Kotlin/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Mon Jul 01 10:02:38 EEST 2019 +#Sun Jul 07 23:57:08 CEST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip diff --git a/Kotlin/lib/build.gradle b/Kotlin/lib/build.gradle index 35dd930e..71417e47 100644 --- a/Kotlin/lib/build.gradle +++ b/Kotlin/lib/build.gradle @@ -1,14 +1,14 @@ apply plugin: 'com.android.library' -apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-android' android { - compileSdkVersion 29 + compileSdk 34 + namespace "com.wolt.blurhashkt" defaultConfig { - minSdkVersion 14 - targetSdkVersion 29 + minSdkVersion 21 + targetSdkVersion 34 versionCode 1 versionName "1.0" @@ -22,11 +22,16 @@ android { } } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } } dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - androidTestImplementation 'junit:junit:4.13' - androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test:runner:1.6.1' } diff --git a/Kotlin/lib/src/main/AndroidManifest.xml b/Kotlin/lib/src/main/AndroidManifest.xml deleted file mode 100644 index 058adf02..00000000 --- a/Kotlin/lib/src/main/AndroidManifest.xml +++ /dev/null @@ -1 +0,0 @@ - From c1e50e7bd3f2622d6c45f21d4db17038a9ef05a6 Mon Sep 17 00:00:00 2001 From: Christophe Beyls Date: Mon, 8 Jul 2024 00:46:35 +0200 Subject: [PATCH 2/3] feat: reimplement BlurHashDecoder to make it faster and remove the cache --- .../wolt/blurhashkt/BlurHashDecoderTest.kt | 58 +------ .../com/wolt/blurhashkt/BlurHashDecoder.kt | 152 ++++++------------ 2 files changed, 53 insertions(+), 157 deletions(-) diff --git a/Kotlin/lib/src/androidTest/java/com/wolt/blurhashkt/BlurHashDecoderTest.kt b/Kotlin/lib/src/androidTest/java/com/wolt/blurhashkt/BlurHashDecoderTest.kt index 5b5a5d54..b073c0e8 100644 --- a/Kotlin/lib/src/androidTest/java/com/wolt/blurhashkt/BlurHashDecoderTest.kt +++ b/Kotlin/lib/src/androidTest/java/com/wolt/blurhashkt/BlurHashDecoderTest.kt @@ -1,24 +1,15 @@ package com.wolt.blurhashkt import android.graphics.Bitmap -import com.wolt.blurhashkt.BlurHashDecoder.clearCache import com.wolt.blurhashkt.BlurHashDecoder.decode -import junit.framework.Assert.assertTrue -import org.junit.Before +import org.junit.Assert.assertArrayEquals import org.junit.Test import java.nio.ByteBuffer -import java.util.* class BlurHashDecoderTest { - @Before - @Throws(Exception::class) - fun setUp() { - clearCache() - } - @Test - fun decode_smallImage_cacheEnabled_shouldGetTheSameBitmapInManyRequests() { + fun decode_smallImage_shouldGetTheSameBitmapInManyRequests() { val bmp1 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12)!! val bmp2 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12)!! val bmp3 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12)!! @@ -28,27 +19,7 @@ class BlurHashDecoderTest { } @Test - fun decode_smallImage_differentCache_shouldGetTheSameBitmapInManyRequests() { - val bmp1 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12)!! - val bmp2 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, useCache = false)!! - val bmp3 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12)!! - - bmp1.assertEquals(bmp2) - bmp2.assertEquals(bmp3) - } - - @Test - fun decode_smallImage_cacheDisabled_shouldGetTheSameBitmapInManyRequests() { - val bmp1 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, useCache = false)!! - val bmp2 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, useCache = false)!! - val bmp3 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, useCache = false)!! - - bmp1.assertEquals(bmp2) - bmp2.assertEquals(bmp3) - } - - @Test - fun decode_bigImage_cacheEnabled_shouldGetTheSameBitmapInManyRequests() { + fun decode_bigImage_shouldGetTheSameBitmapInManyRequests() { val bmp1 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100)!! val bmp2 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100)!! val bmp3 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100)!! @@ -56,26 +27,6 @@ class BlurHashDecoderTest { bmp1.assertEquals(bmp2) bmp2.assertEquals(bmp3) } - - @Test - fun decode_bigImage_differentCache_shouldGetTheSameBitmapInManyRequests() { - val bmp1 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100)!! - val bmp2 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, useCache = false)!! - val bmp3 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100)!! - - bmp1.assertEquals(bmp2) - bmp2.assertEquals(bmp3) - } - - @Test - fun decode_bigImage_cacheDisabled_shouldGetTheSameBitmapInManyRequests() { - val bmp1 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, useCache = false)!! - val bmp2 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, useCache = false)!! - val bmp3 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, useCache = false)!! - - bmp1.assertEquals(bmp2) - bmp2.assertEquals(bmp3) - } } fun Bitmap.assertEquals(bitmap2: Bitmap) { @@ -83,6 +34,5 @@ fun Bitmap.assertEquals(bitmap2: Bitmap) { copyPixelsToBuffer(buffer1) val buffer2: ByteBuffer = ByteBuffer.allocate(bitmap2.height * bitmap2.rowBytes) bitmap2.copyPixelsToBuffer(buffer2) - val equals = Arrays.equals(buffer1.array(), buffer2.array()) - assertTrue(equals) + assertArrayEquals(buffer1.array(), buffer2.array()) } diff --git a/Kotlin/lib/src/main/java/com/wolt/blurhashkt/BlurHashDecoder.kt b/Kotlin/lib/src/main/java/com/wolt/blurhashkt/BlurHashDecoder.kt index 3278868b..cdd8c544 100644 --- a/Kotlin/lib/src/main/java/com/wolt/blurhashkt/BlurHashDecoder.kt +++ b/Kotlin/lib/src/main/java/com/wolt/blurhashkt/BlurHashDecoder.kt @@ -2,64 +2,44 @@ package com.wolt.blurhashkt import android.graphics.Bitmap import android.graphics.Color +import kotlin.math.PI import kotlin.math.cos import kotlin.math.pow import kotlin.math.withSign object BlurHashDecoder { - // cache Math.cos() calculations to improve performance. - // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps - // the cache is enabled by default, it is recommended to disable it only when just a few images are displayed - private val cacheCosinesX = HashMap() - private val cacheCosinesY = HashMap() - - /** - * Clear calculations stored in memory cache. - * The cache is not big, but will increase when many image sizes are used, - * if the app needs memory it is recommended to clear it. - */ - fun clearCache() { - cacheCosinesX.clear() - cacheCosinesY.clear() - } - /** * Decode a blur hash into a new bitmap. - * - * @param useCache use in memory cache for the calculated math, reused by images with same size. - * if the cache does not exist yet it will be created and populated with new calculations. - * By default it is true. */ - fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? { + fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f): Bitmap? { if (blurHash == null || blurHash.length < 6) { return null } val numCompEnc = decode83(blurHash, 0, 1) val numCompX = (numCompEnc % 9) + 1 val numCompY = (numCompEnc / 9) + 1 - if (blurHash.length != 4 + 2 * numCompX * numCompY) { + val totalComp = numCompX * numCompY + if (blurHash.length != 4 + 2 * totalComp) { return null } val maxAcEnc = decode83(blurHash, 1, 2) val maxAc = (maxAcEnc + 1) / 166f - val colors = Array(numCompX * numCompY) { i -> - if (i == 0) { - val colorEnc = decode83(blurHash, 2, 6) - decodeDc(colorEnc) - } else { - val from = 4 + i * 2 - val colorEnc = decode83(blurHash, from, from + 2) - decodeAc(colorEnc, maxAc * punch) - } + val colors = FloatArray(totalComp * 3) + var colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc, colors) + for (i in 1 until totalComp) { + val from = 4 + i * 2 + colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch, colors, i * 3) } - return composeBitmap(width, height, numCompX, numCompY, colors, useCache) + return composeBitmap(width, height, numCompX, numCompY, colors) } - private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { + private fun decode83(str: String, from: Int, to: Int): Int { var result = 0 for (i in from until to) { - val index = charMap[str[i]] ?: -1 + val index = CHARS.indexOf(str[i]) if (index != -1) { result = result * 83 + index } @@ -67,11 +47,13 @@ object BlurHashDecoder { return result } - private fun decodeDc(colorEnc: Int): FloatArray { - val r = colorEnc shr 16 - val g = (colorEnc shr 8) and 255 - val b = colorEnc and 255 - return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + private fun decodeDc(colorEnc: Int, outArray: FloatArray) { + val r = (colorEnc shr 16) and 0xFF + val g = (colorEnc shr 8) and 0xFF + val b = colorEnc and 0xFF + outArray[0] = srgbToLinear(r) + outArray[1] = srgbToLinear(g) + outArray[2] = srgbToLinear(b) } private fun srgbToLinear(colorEnc: Int): Float { @@ -83,84 +65,57 @@ object BlurHashDecoder { } } - private fun decodeAc(value: Int, maxAc: Float): FloatArray { + private fun decodeAc(value: Int, maxAc: Float, outArray: FloatArray, outIndex: Int) { val r = value / (19 * 19) val g = (value / 19) % 19 val b = value % 19 - return floatArrayOf( - signedPow2((r - 9) / 9.0f) * maxAc, - signedPow2((g - 9) / 9.0f) * maxAc, - signedPow2((b - 9) / 9.0f) * maxAc - ) + outArray[outIndex] = signedPow2((r - 9) / 9.0f) * maxAc + outArray[outIndex + 1] = signedPow2((g - 9) / 9.0f) * maxAc + outArray[outIndex + 2] = signedPow2((b - 9) / 9.0f) * maxAc } - private fun signedPow2(value: Float) = value.pow(2f).withSign(value) + private fun signedPow2(value: Float) = (value * value).withSign(value) private fun composeBitmap( - width: Int, height: Int, - numCompX: Int, numCompY: Int, - colors: Array, - useCache: Boolean + width: Int, height: Int, + numCompX: Int, numCompY: Int, + colors: FloatArray ): Bitmap { // use an array for better performance when writing pixel colors val imageArray = IntArray(width * height) - val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) - val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) - val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) - val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) + val cosinesX = createCosines(width, numCompX) + val cosinesY = if (width == height && numCompX == numCompY) { + cosinesX + } else { + createCosines(height, numCompY) + } 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 cosY = cosinesY[y * numCompY + j] for (i in 0 until numCompX) { - val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width) - val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height) - val basis = (cosX * cosY).toFloat() - val color = colors[j * numCompX + i] - r += color[0] * basis - g += color[1] * basis - b += color[2] * basis + val cosX = cosinesX[x * numCompX + i] + val basis = cosX * cosY + val colorIndex = (j * numCompX + i) * 3 + r += colors[colorIndex] * basis + g += colors[colorIndex + 1] * basis + b += colors[colorIndex + 2] * basis } } - imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) + imageArray[x + width * y] = + Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) } } return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) } - private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when { - calculate -> { - DoubleArray(height * numCompY).also { - cacheCosinesY[height * numCompY] = it - } - } - else -> { - cacheCosinesY[height * numCompY]!! - } - } - - private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when { - calculate -> { - DoubleArray(width * numCompX).also { - cacheCosinesX[width * numCompX] = it - } - } - else -> cacheCosinesX[width * numCompX]!! - } - - private fun DoubleArray.getCos( - calculate: Boolean, - x: Int, - numComp: Int, - y: Int, - size: Int - ): Double { - if (calculate) { - this[x + numComp * y] = cos(Math.PI * y * x / size) - } - return this[x + numComp * y] + private fun createCosines(size: Int, numComp: Int) = FloatArray(size * numComp) { index -> + val x = index / numComp + val i = index % numComp + cos(PI * x * i / size).toFloat() } private fun linearToSrgb(value: Float): Int { @@ -172,14 +127,5 @@ object BlurHashDecoder { } } - private val charMap = listOf( - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', - 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', - 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', - 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', - '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' - ) - .mapIndexed { i, c -> c to i } - .toMap() - + private const val CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" } From 927ec9f92b6ca7445aaefffbd566ca5ea0469685 Mon Sep 17 00:00:00 2001 From: Christophe Beyls Date: Mon, 8 Jul 2024 22:55:22 +0200 Subject: [PATCH 3/3] chore: move namespace before compileSdk --- Kotlin/demo/build.gradle | 3 +-- Kotlin/lib/build.gradle | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Kotlin/demo/build.gradle b/Kotlin/demo/build.gradle index aeb771a5..f763b540 100644 --- a/Kotlin/demo/build.gradle +++ b/Kotlin/demo/build.gradle @@ -2,9 +2,8 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' android { - - compileSdk 34 namespace "com.wolt.blurhashapp" + compileSdk 34 defaultConfig { applicationId "com.wolt.blurhash" diff --git a/Kotlin/lib/build.gradle b/Kotlin/lib/build.gradle index 71417e47..2921eb01 100644 --- a/Kotlin/lib/build.gradle +++ b/Kotlin/lib/build.gradle @@ -2,9 +2,8 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' android { - - compileSdk 34 namespace "com.wolt.blurhashkt" + compileSdk 34 defaultConfig { minSdkVersion 21