Files
simplex-chat/apps/multiplatform/common/build.gradle.kts
T
Narasimha-scandsh 1196d362ee desktop: animate GIFs and animated WebP (#7365)
* desktop: add bounded animated image decoder

Skia's Codec is already on the desktop classpath through skiko and decodes
both GIF and animated WebP. The frames come from a file somebody else
composed, so the decoder is bounded before it allocates: the raster is
measured in bytes with the sides multiplied as Long, each side is capped
separately so an extreme aspect ratio cannot slip under the byte budget, and
the encoded size is checked before the bytes are copied into native memory.
Anything outside the bounds, or any failure, keeps the still image the chat
already renders.

Nothing calls this yet.

* desktop: animate GIFs in chat items and full screen

Both views drew the first frame only. The full screen view also decoded its
still on every recomposition, which an animation recomposes once per frame,
so that decode is remembered against the data it comes from.

The chat list preview stays a still image: it is a 36dp box that the desktop
layout keeps on screen the whole time, so animating it would hold a raster and
spend a frame of work per listed chat, without pause.

Removes the two markers left for this work.

* desktop: don't decode animation frames that cannot be seen

With media blur on, a blurred image is only revealed while the mouse is over
it, so every frame was decoded, uploaded and then blurred away again for
nobody - and the blur is a render effect re-run per frame. Frames now decode
only while the image can be seen, which also stops motion showing through a
blur that is there to hide it.

Passing the blur state to the view is why the shared signature changes; coil
drives its own animation on Android, so there is nothing to pause there.

* docs: move animated images plan to plans/

* docs: drop file path references from animated images plan

* docs: correct animated images plan against the code

* desktop: correct animated image comments

* desktop: reduce animated image comments

* desktop: correct and bound animated image decoding

* docs: correct animated images plan against measurements

* desktop: fuse the animation prior frame decision

* docs: cover desktop animated images in spec and product

* desktop: drop the unused animated image component

* desktop: return the animation frame instead of its state

* docs: correct the animated images documentation

* desktop: don't decode animations under the full screen viewer

* desktop: bound the frames an animation rebuilds

* desktop: pause animations under any full screen modal

* desktop: stop animations that alternate expensive frames

* desktop: read what playing a frame needs only once

* desktop: close the codec of an animation outside the bounds

* desktop: wait out what an animation frame cost to decode

* docs: correct animated images claims against the code

* desktop: bound the frame count where the others are bounded

* desktop: don't wait out a stall an animation frame did not spend

* desktop: say what the slow frame constants stand for

* desktop: don't decode animations behind a minimised window

* desktop: make the animation frame wait testable

* desktop: bound the file size where the others are bounded

* desktop: pin the frame wait clamp in its test

* desktop: keep the frame wait clamp private

* desktop: reduce animated image comments

---------

Co-authored-by: sh <github.shum@liber.li>
2026-08-21 20:30:11 +01:00

362 lines
14 KiB
Kotlin

plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
id("com.android.library")
id("org.jetbrains.kotlin.plugin.serialization")
id("org.jetbrains.kotlin.plugin.compose")
id("dev.icerock.mobile.multiplatform-resources")
id("com.github.gmazzo.buildconfig") version "5.3.5"
}
group = "chat.simplex"
version = extra["android.version_name"] as String
val simplexAssetsDir = rootProject.findProperty("simplex.assets.dir") as String?
val simplexAssetsLocal = file("src/commonMain/resources/assets/simplex")
val hasSimplexAssets = simplexAssetsDir != null
if (simplexAssetsDir != null) {
val resolvedAssetsDir = rootProject.rootDir.resolve(simplexAssetsDir)
val srcImagesDir = resolvedAssetsDir.resolve("multiplatform/resources/MR/images")
val verifySimplexAssets = tasks.register("verifySimplexAssets") {
doLast {
if (!srcImagesDir.isDirectory) {
throw GradleException("Source assets not found: $srcImagesDir (run resize.sh first)")
}
}
}
tasks.register<Sync>("copySimplexAssets") {
dependsOn(verifySimplexAssets)
from(srcImagesDir)
into(simplexAssetsLocal.resolve("MR/images"))
}
} else {
tasks.register<Delete>("cleanSimplexAssets") {
delete(simplexAssetsLocal)
}
}
kotlin {
androidTarget()
jvm("desktop")
applyDefaultHierarchyTemplate()
sourceSets {
all {
languageSettings {
optIn("kotlinx.coroutines.DelicateCoroutinesApi")
optIn("androidx.compose.foundation.ExperimentalFoundationApi")
optIn("androidx.compose.ui.text.ExperimentalTextApi")
optIn("androidx.compose.material.ExperimentalMaterialApi")
optIn("com.arkivanov.decompose.ExperimentalDecomposeApi")
optIn("kotlinx.serialization.InternalSerializationApi")
optIn("kotlinx.serialization.ExperimentalSerializationApi")
optIn("androidx.compose.ui.ExperimentalComposeUiApi")
optIn("com.google.accompanist.permissions.ExperimentalPermissionsApi")
}
}
val commonMain by getting {
if (hasSimplexAssets) {
resources.srcDir(simplexAssetsLocal)
} else {
resources.srcDir("src/commonMain/resources/assets/default")
}
dependencies {
api(compose.runtime)
api(compose.foundation)
api(compose.material)
api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
api("org.jetbrains.kotlinx:kotlinx-datetime:0.5.0")
api("com.russhwolf:multiplatform-settings:1.1.1")
api("com.charleskorn.kaml:kaml:0.59.0")
api("org.jetbrains.compose.ui:ui-text:${rootProject.extra["compose.version"] as String}")
implementation("org.jetbrains.compose.material:material-icons-core:1.7.3")
implementation("org.jetbrains.compose.material:material-icons-extended:1.7.3")
//Barcode
api("org.boofcv:boofcv-core:1.1.3")
implementation("com.godaddy.android.colorpicker:compose-color-picker-jvm:0.7.0")
// Link Previews
implementation("org.jsoup:jsoup:1.17.2")
// Resources
api("dev.icerock.moko:resources:0.23.0")
api("dev.icerock.moko:resources-compose:0.23.0")
// Markdown
implementation("com.mikepenz:multiplatform-markdown-renderer:0.27.0")
implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.27.0")
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation(kotlin("test-junit"))
implementation(kotlin("test-common"))
implementation(kotlin("test-annotations-common"))
}
}
val androidMain by getting {
kotlin.srcDir("build/generated/moko/androidMain/src")
dependencies {
implementation("androidx.activity:activity-compose:1.9.1")
val workVersion = "2.9.1"
implementation("androidx.work:work-runtime-ktx:$workVersion")
// Video support
implementation("com.google.android.exoplayer:exoplayer:2.19.1")
// Biometric authentication
implementation("androidx.biometric:biometric:1.2.0-alpha05")
//Barcode
implementation("org.boofcv:boofcv-android:1.1.3")
//Camera Permission
implementation("com.google.accompanist:accompanist-permissions:0.34.0")
implementation("androidx.webkit:webkit:1.11.0")
// GIFs support
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("io.coil-kt:coil-gif:2.6.0")
// Emojis
implementation("androidx.emoji2:emoji2-emojipicker:1.4.0")
implementation("com.jakewharton:process-phoenix:3.0.0")
// https://issuetracker.google.com/issues/351313880
val cameraXVersion = "1.5.1"
implementation("androidx.camera:camera-core:${cameraXVersion}")
implementation("androidx.camera:camera-camera2:${cameraXVersion}")
implementation("androidx.camera:camera-lifecycle:${cameraXVersion}")
implementation("androidx.camera:camera-view:${cameraXVersion}")
// Calls lifecycle listener
implementation("androidx.lifecycle:lifecycle-process:2.8.4")
}
}
val desktopMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.0")
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.8") {
exclude("net.java.dev.jna")
}
// For jSystemThemeDetector only
implementation("net.java.dev.jna:jna-platform:5.14.0")
implementation("com.sshtools:two-slices:0.9.1")
implementation("org.slf4j:slf4j-simple:2.0.12")
implementation("uk.co.caprica:vlcj:4.8.3")
implementation("net.java.dev.jna:jna:5.14.0")
implementation(project(":external:nanohttpd"))
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
}
val desktopTest by getting
}
}
android {
namespace = "chat.simplex.common"
compileSdk = 35
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
defaultConfig {
minSdk = 26
}
testOptions.targetSdk = 34
lint.targetSdk = 34
val isAndroid = gradle.startParameter.taskNames.find {
val lower = it.lowercase()
lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install")
} != null
if (isAndroid) {
// This is not needed on Android but can't be moved to desktopMain because MR lib don't support this.
// No other ways to exclude a file work, but it's large and should be excluded
kotlin.sourceSets["commonMain"].resources.exclude("/MR/fonts/NotoColorEmoji-Regular.ttf")
}
}
multiplatformResources {
multiplatformResourcesPackage = "chat.simplex.res"
// multiplatformResourcesClassName = "MR"
}
buildConfig {
forClass("BuildConfigCommon") {
buildConfigField("String", "ANDROID_VERSION_NAME", "\"${extra["android.version_name"]}\"")
buildConfigField("int", "ANDROID_VERSION_CODE", "${extra["android.version_code"]}")
buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"")
buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}")
buildConfigField("String", "DATABASE_BACKEND", "\"${extra["database.backend"]}\"")
buildConfigField("Boolean", "SIMPLEX_ASSETS", "$hasSimplexAssets")
}
}
afterEvaluate {
tasks.named("generateMRcommonMain") {
dependsOn("adjustFormatting")
if (hasSimplexAssets) {
dependsOn("copySimplexAssets")
} else {
dependsOn("cleanSimplexAssets")
}
}
tasks.create("adjustFormatting") {
doLast {
val debug = false
val stringRegex = Regex(".*<string .*</string>.*")
val startStringRegex = Regex("<string [^>]*>")
val endStringRegex = Regex("</string>[ ]*")
val endTagRegex = Regex("</")
val anyHtmlRegex = Regex("[^>]*>.*(<|>).*</string>|[^>]*>.*(&lt;|&gt;).*</string>")
val fontLtGtRegex = Regex("[^>]*>.*&lt;font[^>]*&gt;.*&lt;/font&gt;.*</string>")
val unbracketedColorRegex = Regex("color=#[abcdefABCDEF0-9]{3,6}")
val correctHtmlRegex = Regex("[^>]*>.*<b>.*</b>.*</string>|[^>]*>.*<i>.*</i>.*</string>|[^>]*>.*<u>.*</u>.*</string>|[^>]*>.*<font[^>]*>.*</font>.*</string>")
val possibleFormat = listOf("s", "d", "1\$s", "2\$s", "3\$s", "4\$s", "1\$d", "2\$d", "3\$d", "4\$d", "2s", "f")
fun String.id(): String = replace("<string name=\"", "").trim().substringBefore("\"")
fun String.formatting(filepath: String): List<String> {
if (!contains("%")) return emptyList()
val value = substringAfter("\">").substringBeforeLast("</string>")
val formats = ArrayList<String>()
var substring = value.substringAfter("%")
while (true) {
var foundFormat = false
for (format in possibleFormat) {
if (substring.startsWith(format)) {
formats.add(format)
foundFormat = true
break
}
}
if (!foundFormat) {
throw Exception("Unknown formatting in string. Add it to 'possibleFormat' in common/build.gradle.kts if needed: $this \nin $filepath")
}
val was = substring
substring = substring.substringAfter("%")
if (was.length == substring.length) break
}
return if (formats.any { it.startsWith("1$") || it.startsWith("2$") || it.startsWith("3$") || it.startsWith("4$") }) {
formats.sortedBy { it.trim('s', 'd', 'f', '$').toIntOrNull() ?: throw Exception("Formatting don't have positional arguments: $this \nin $filepath") }
} else {
formats
}
}
fun String.removeCDATA(): String =
if (contains("<![CDATA")) {
replace("<![CDATA[", "").replace("]]></string>", "</string>")
} else {
this
}
fun String.addCDATA(filepath: String): String {
//return this
if (anyHtmlRegex.matches(this)) {
val prepared = if (fontLtGtRegex.matches(this) || unbracketedColorRegex.containsMatchIn(this)) {
replace("&lt;", "<").replace("&gt;", ">").replace(unbracketedColorRegex) { it.value.replace("color=#", "color=\"#") + "\"" }
} else this
val countOfStartTag = prepared.count { it == '<' }
val countOfEndTag = prepared.count { it == '>' }
if (countOfStartTag != countOfEndTag || countOfStartTag != endTagRegex.findAll(prepared).count() * 2 || !correctHtmlRegex.matches(prepared)) {
if (debug) {
println("Wrong string:")
println(this)
println("in $filepath")
println(" ")
} else {
throw Exception("Wrong string: $this \nin $filepath")
}
}
val res = prepared.replace(startStringRegex) { it.value + "<![CDATA[" }.replace(endStringRegex) { "]]>" + it.value }
if (debug) {
println("Changed string:")
println(this)
println(res)
println(" ")
}
return res
}
if (debug) {
println("Correct string:")
println(this)
println(" ")
}
return this
}
val fileRegex = Regex("MR/../strings.xml$|MR/..-.../strings.xml$|MR/..-../strings.xml$|MR/base/strings.xml$")
val tree = kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree
val baseStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("base/strings.xml") } ?: throw Exception("No base/strings.xml found")
val lvStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("lv/strings.xml") } ?: throw Exception("No base/strings.xml found")
val treeList = ArrayList(tree.toList())
treeList.remove(baseStringsFile)
// removed lv/strings.xml file with 100+ errors
treeList.remove(lvStringsFile)
treeList.add(0, baseStringsFile)
val baseFormatting = mutableMapOf<String, List<String>>()
treeList.forEachIndexed { index, file ->
val isBase = index == 0
val initialLines = ArrayList<String>()
val finalLines = ArrayList<String>()
val errors = ArrayList<String>()
file.useLines { lines ->
val multiline = ArrayList<String>()
lines.forEach { line ->
initialLines.add(line)
if (stringRegex.matches(line)) {
val fixedLine = line.removeCDATA().addCDATA(file.absolutePath)
val lineId = fixedLine.id()
if (isBase) {
baseFormatting[lineId] = fixedLine.formatting(file.absolutePath)
} else if (baseFormatting[lineId] != fixedLine.formatting(file.absolutePath)) {
errors.add("Incorrect formatting in string: $fixedLine \nin ${file.absolutePath}.\n" +
"If you want to remove non-base translation, search this Regex and replace with empty value in IDE:\n" +
"[ ]*<.*\"${line.substringAfter("\"").substringBefore("\"")}\"[^/]*\\n*.*string>\\n")
}
finalLines.add(fixedLine)
} else if (multiline.isEmpty() && startStringRegex.containsMatchIn(line)) {
multiline.add(line)
} else if (multiline.isNotEmpty() && endStringRegex.containsMatchIn(line)) {
multiline.add(line)
val fixedLines = multiline.joinToString("\n").removeCDATA().addCDATA(file.absolutePath).split("\n")
val fixedLinesJoined = fixedLines.joinToString("")
val lineId = fixedLinesJoined.id()
if (isBase) {
baseFormatting[lineId] = fixedLinesJoined.formatting(file.absolutePath)
} else if (baseFormatting[lineId] != fixedLinesJoined.formatting(file.absolutePath)) {
errors.add("Incorrect formatting in string: $fixedLinesJoined \nin ${file.absolutePath}")
}
finalLines.addAll(fixedLines)
multiline.clear()
} else if (multiline.isNotEmpty()) {
multiline.add(line)
} else {
finalLines.add(line)
}
}
if (multiline.isNotEmpty()) {
errors.add("Unclosed string tag: ${multiline.joinToString("\n")} \nin ${file.absolutePath}")
}
}
if (errors.isNotEmpty()) {
throw Exception("Found errors: \n\n${errors.joinToString("\n\n")}")
}
if (!debug && finalLines != initialLines) {
file.writer().use {
finalLines.forEachIndexed { index, line ->
it.write(line)
if (index != finalLines.lastIndex) {
it.write("\n")
}
}
}
}
}
}
}
}