mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 06:59:42 +00:00
* 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>
362 lines
14 KiB
Kotlin
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>|[^>]*>.*(<|>).*</string>")
|
|
val fontLtGtRegex = Regex("[^>]*>.*<font[^>]*>.*</font>.*</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("<", "<").replace(">", ">").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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|