Show pictures in descriptions, or links to them when turned off

[img] in a channel, client or server description is now a picture on both
clients, as TeamSpeak shows it; chat keeps it a link. Only web addresses
become pictures. While one loads it is TeamSpeak's loading icon; one that
cannot be had is its address behind the broken-picture icon.

"Show pictures in descriptions" (desktop: Chat settings; Android: Appearance)
turns them into links behind a picture icon instead, fetching nothing. A
picture that was itself a link keeps that link beside it.

Core's PictureDownloads fetches them for both clients: kept on disk for a
month after last shown, at most 8 MB, redirects followed across http and
https, and on Android plain http asked for over https. The desktop draws
them narrowed to the info panel, Android at a pixel to a dp, animated GIFs
playing on both; long addresses are shortened to host and file name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 23:06:13 +00:00
parent fd86d9e638
commit af0564702e
19 changed files with 805 additions and 68 deletions

View File

@@ -0,0 +1,42 @@
package com.ts3client.android
import android.graphics.ImageDecoder
import android.graphics.drawable.AnimatedImageDrawable
import android.graphics.drawable.BitmapDrawable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import java.nio.ByteBuffer
/** An avatar or a picture from a description: still, or moving like an animated GIF. */
sealed interface Picture {
val width: Int
val height: Int
data class Still(val bitmap: ImageBitmap) : Picture {
override val width get() = bitmap.width
override val height get() = bitmap.height
}
/** Shared by whatever shows it: one animation, drawn wherever it is on screen. */
data class Moving(val drawable: AnimatedImageDrawable) : Picture {
override val width get() = drawable.intrinsicWidth
override val height get() = drawable.intrinsicHeight
}
}
/** Decodes any image Android knows, no larger than [maxPx] on its longer side; null if it is none. */
internal fun decodePicture(data: ByteArray, maxPx: Int): Picture? = try {
val drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(ByteBuffer.wrap(data))) { decoder, info, _ ->
val largest = maxOf(info.size.width, info.size.height)
if (largest > maxPx) {
decoder.setTargetSize(info.size.width * maxPx / largest, info.size.height * maxPx / largest)
}
}
when (drawable) {
is AnimatedImageDrawable -> Picture.Moving(drawable)
is BitmapDrawable -> Picture.Still(drawable.bitmap.asImageBitmap())
else -> null
}
} catch (e: Exception) {
null
}

View File

@@ -1,16 +1,12 @@
package com.ts3client.android
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.graphics.drawable.AnimatedImageDrawable
import android.graphics.drawable.BitmapDrawable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import com.ts3client.net.TeamspeakConnection
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
/**
@@ -20,7 +16,7 @@ import java.util.concurrent.ConcurrentHashMap
class ServerImages internal constructor(private val conn: TeamspeakConnection) {
private val icons = ConcurrentHashMap<Long, ImageBitmap>()
private val avatars = ConcurrentHashMap<String, AvatarImage>()
private val avatars = ConcurrentHashMap<String, Picture>()
private val _version = MutableStateFlow(0)
val version: StateFlow<Int> = _version
@@ -35,30 +31,12 @@ class ServerImages internal constructor(private val conn: TeamspeakConnection) {
return bitmap
}
fun avatar(uniqueId: String, flag: String): AvatarImage? {
fun avatar(uniqueId: String, flag: String): Picture? {
if (flag.isEmpty()) return null
avatars[flag]?.let { return it }
val avatar = conn.avatars.get(uniqueId, flag) ?: return null
val image = if (avatar.isAnimated) decodeAnimated(avatar.data()) else decode(avatar.data(), AVATAR_PX)?.let(AvatarImage::Still)
if (image != null) avatars[flag] = image
return image
}
/** A GIF as it moves; one of a single frame stays still. */
private fun decodeAnimated(data: ByteArray): AvatarImage? = try {
val drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(ByteBuffer.wrap(data))) { decoder, info, _ ->
val largest = maxOf(info.size.width, info.size.height)
if (largest > AVATAR_PX) {
decoder.setTargetSize(info.size.width * AVATAR_PX / largest, info.size.height * AVATAR_PX / largest)
}
}
when (drawable) {
is AnimatedImageDrawable -> AvatarImage.Moving(drawable)
is BitmapDrawable -> AvatarImage.Still(drawable.bitmap.asImageBitmap())
else -> null
}
} catch (e: Exception) {
decode(data, AVATAR_PX)?.let(AvatarImage::Still)
val picture = conn.avatars.get(uniqueId, flag)?.let { decodePicture(it.data(), AVATAR_PX) } ?: return null
avatars[flag] = picture
return picture
}
/** Decoded no larger than needed: avatars may be far bigger than they are ever shown. */
@@ -77,10 +55,3 @@ class ServerImages internal constructor(private val conn: TeamspeakConnection) {
const val AVATAR_PX = 512
}
}
sealed interface AvatarImage {
data class Still(val bitmap: ImageBitmap) : AvatarImage
/** Shared by whatever shows it: one animation, drawn wherever it is on screen. */
data class Moving(val drawable: AnimatedImageDrawable) : AvatarImage
}

View File

@@ -66,6 +66,7 @@ class SessionController(private val context: Context) {
val settings: Settings = Settings.load()
val icons = IconTheme(context, settings)
val pictures = WebPictures(context.cacheDir, settings)
private val bookmarkStore = Bookmarks.load()
internal val identityStore = IdentityStore.load(settings)
val identities = IdentityManager(context, identityStore, settings, bookmarkStore) {
@@ -318,6 +319,11 @@ class SessionController(private val context: Context) {
val soundPack: SoundPack? get() = sounds.pack()
fun setDescriptionPictures(shown: Boolean) {
settings.descriptionPictures = shown
settings.save()
}
fun setSoundPack(pack: SoundPack) {
sounds.setPack(pack)
settings.save()

View File

@@ -0,0 +1,55 @@
package com.ts3client.android
import android.util.LruCache
import com.ts3client.config.Settings
import com.ts3client.net.PictureDownloads
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.ConcurrentHashMap
/** The pictures descriptions show, fetched by core's downloads and decoded while there is room. */
class WebPictures(cacheDir: File, private val settings: Settings) {
sealed interface State {
data object Loading : State
data object Broken : State
data class Shown(val picture: Picture) : State
}
private val downloads = PictureDownloads(File(cacheDir, "pictures"), false)
private val decoded = LruCache<String, Picture>(CACHED)
private val undecodable = ConcurrentHashMap.newKeySet<String>()
private val _version = MutableStateFlow(0)
/** Moves on whenever a download has come or failed. */
val version: StateFlow<Int> = _version
init {
downloads.addListener { _version.update { it + 1 } }
}
/** Whether pictures are shown at all, rather than links to them. */
val enabled: Boolean get() = settings.descriptionPictures
suspend fun state(url: String): State {
decoded.get(url)?.let { return State.Shown(it) }
if (url in undecodable) return State.Broken
val file = downloads.get(url) ?: return if (downloads.failed(url)) State.Broken else State.Loading
val picture = withContext(Dispatchers.IO) { decodePicture(file.readBytes(), MAX_PX) }
if (picture == null) {
undecodable += url
return State.Broken
}
decoded.put(url, picture)
return State.Shown(picture)
}
private companion object {
const val CACHED = 32
const val MAX_PX = 1024
}
}

View File

@@ -70,7 +70,7 @@ fun App(vm: SessionController) {
contactDirect = uniqueId != null
}
CompositionLocalProvider(LocalIconTheme provides vm.icons) {
CompositionLocalProvider(LocalIconTheme provides vm.icons, LocalWebPictures provides vm.pictures) {
val server = current
when {
settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false })

View File

@@ -1,15 +1,24 @@
package com.ts3client.android.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
@@ -20,26 +29,161 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.WebPictures
import com.ts3client.text.BBCode
import com.ts3client.text.ChatHtml
/**
* HTML as [BBCode][com.ts3client.text.BBCode] and [ChatHtml] write it, with its rules as dividers:
* Android's HTML parser knows none, so the text is split at them.
* HTML as [BBCode][com.ts3client.text.BBCode] and [ChatHtml] write it, with its rules as dividers
* and its pictures fetched from the web: Android's HTML parser shows neither, so the text is
* split at them.
*/
@Composable
fun RichText(html: String, onLink: (String) -> Unit, modifier: Modifier = Modifier, style: TextStyle = LocalTextStyle.current) {
val link by rememberUpdatedState(onLink)
val color = linkColor()
val parts = remember(html, color) { html.split(RULE).map { chatText(it, color) { href -> link(href) } } }
val pieces = remember(html, color) { split(html) { chatText(it, color) { href -> link(href) } } }
Column(modifier) {
parts.forEachIndexed { i, text ->
if (i > 0) HorizontalDivider(Modifier.padding(vertical = 6.dp))
// As wide as there is room, for aligned paragraphs to have somewhere to go.
if (text.isNotEmpty()) Text(text, Modifier.fillMaxWidth(), style = style)
for (piece in pieces) {
when (piece) {
Piece.Rule -> HorizontalDivider(Modifier.padding(vertical = 6.dp))
is Piece.Image -> WebPicture(piece, style) { link(it) }
// As wide as there is room, for aligned paragraphs to have somewhere to go.
is Piece.Text -> if (piece.text.isNotEmpty()) Text(piece.text, Modifier.fillMaxWidth(), style = style)
}
}
}
}
/** The web pictures descriptions show. */
val LocalWebPictures = staticCompositionLocalOf<WebPictures?> { null }
private sealed interface Piece {
data class Text(val text: AnnotatedString) : Piece
data object Rule : Piece
/** @param href where the picture links to, if it sits in a link */
data class Image(val url: String, val href: String?, val align: Alignment.Horizontal) : Piece
}
/**
* [html] cut at its rules and pictures. Each piece of text is closed where it is cut and the
* next reopens what it was in, so an aligned block or a link carries on past a picture.
*/
private fun split(html: String, text: (String) -> AnnotatedString): List<Piece> {
val pieces = ArrayList<Piece>()
val divs = ArrayList<String>()
var anchor: String? = null
var href: String? = null
val current = StringBuilder()
fun cut() {
if (anchor != null) current.append("</a>")
repeat(divs.size) { current.append("</div>") }
pieces += Piece.Text(text(current.toString()))
current.setLength(0)
divs.forEach { current.append(it) }
anchor?.let { current.append(it) }
}
var last = 0
for (m in CUT_TAG.findAll(html)) {
current.append(html, last, m.range.first)
last = m.range.last + 1
val tag = m.value
when {
tag.startsWith("<hr", ignoreCase = true) -> {
cut()
pieces += Piece.Rule
}
tag.startsWith("<img", ignoreCase = true) -> {
cut()
pieces += Piece.Image(unescape(m.groupValues[1]), href?.let(::unescape), alignment(divs))
}
tag.startsWith("<div", ignoreCase = true) -> {
divs += tag
current.append(tag)
}
tag.equals("</div>", ignoreCase = true) -> {
divs.removeLastOrNull()
current.append(tag)
}
tag.startsWith("<a", ignoreCase = true) -> {
anchor = tag
href = m.groupValues[2]
current.append(tag)
}
else -> {
anchor = null
href = null
current.append(tag)
}
}
}
current.append(html, last, html.length)
pieces += Piece.Text(text(current.toString()))
return pieces
}
/** Where the innermost aligned block puts what is in it. */
private fun alignment(divs: List<String>): Alignment.Horizontal {
val align = divs.asReversed().firstNotNullOfOrNull { ALIGN.find(it)?.groupValues?.get(1) }
return when (align) {
"center" -> Alignment.CenterHorizontally
"right" -> Alignment.End
else -> Alignment.Start
}
}
private fun unescape(s: String) =
s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&amp;", "&")
/**
* A picture at its own size, a pixel to a dp as TeamSpeak shows it, narrowed to fit. With
* pictures turned off, or when one cannot be had, its address as a link behind a picture icon.
*/
@Composable
private fun WebPicture(image: Piece.Image, style: TextStyle, onLink: (String) -> Unit) {
val pictures = LocalWebPictures.current
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalAlignment = image.align) {
if (pictures == null || !pictures.enabled) {
PictureLink("ICONVIEWER", image, style, onLink)
return@Column
}
val version by pictures.version.collectAsStateWithLifecycle()
val state by produceState<WebPictures.State>(WebPictures.State.Loading, image.url, version) {
value = pictures.state(image.url)
}
when (val shown = state) {
WebPictures.State.Loading -> PackIcon("LOADING_IMAGE", "Loading picture", size = 16.dp)
WebPictures.State.Broken -> PictureLink("BROKEN_IMAGE", image, style, onLink)
is WebPictures.State.Shown -> {
val picture = shown.picture
Image(
rememberPicturePainter(picture),
null,
Modifier
.widthIn(max = picture.width.dp)
.aspectRatio(picture.width.toFloat() / picture.height.coerceAtLeast(1))
.then(if (image.href != null) Modifier.clickable { onLink(image.href) } else Modifier),
)
}
}
}
}
/** The picture's address as a link behind [iconKey], then the link it sat in, if any, so neither is lost. */
@Composable
private fun PictureLink(iconKey: String, image: Piece.Image, style: TextStyle, onLink: (String) -> Unit) {
val color = linkColor()
val text = remember(image, color) {
val links = listOfNotNull(image.url, image.href?.takeIf { it != image.url })
chatText(links.joinToString(" ") { "<a href=\"${BBCode.escape(it)}\">${BBCode.escape(BBCode.shortAddress(it))}</a>" }, color, onLink)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
PackIcon(iconKey, "Picture", size = 16.dp)
Text(text, style = style)
}
}
/**
* HTML without rules as styled text. Android's parser knows no stylesheets, tables,
* numbered lists, font sizes or `align`, so the classes [ChatHtml] marks lines with become
@@ -113,7 +257,11 @@ private val LI = Regex("</?li>")
private val SPAN = Regex("<span style=\"font-size:(\\d+)px\">|<span[^>]*>|</span>")
private val ALIGNS = mapOf("left" to "start", "justify" to "start", "center" to "center", "right" to "end")
private val RULE = Regex("<hr\\s*/?>", RegexOption.IGNORE_CASE)
/** Where the text is cut, and what it has to carry across: aligned blocks and links. */
private val CUT_TAG = Regex(
"<hr\\s*/?>|<img src=\"([^\"]*)\">|<div[^>]*>|</div>|<a [^>]*href=\"([^\"]*)\"[^>]*>|</a>",
RegexOption.IGNORE_CASE,
)
private val CLASS = Regex("class=\"([a-z]+)\"")
private val ALIGN = Regex("align=\"(left|center|right|justify)\"")
private val CELL_END = Regex("</t[dh]>")

View File

@@ -80,7 +80,7 @@ private fun Fact(field: Details.Field) {
/** The description in a box that opens up, when there is more of it than fits folded. */
@Composable
private fun Description(bbcode: String, onLink: (String) -> Unit) {
val html = remember(bbcode) { BBCode.toHtml(bbcode) }
val html = remember(bbcode) { BBCode.toHtml(bbcode, true) }
var expanded by rememberSaveable(bbcode) { mutableStateOf(false) }
var overflows by remember(bbcode) { mutableStateOf(false) }
val expandable = overflows || expanded

View File

@@ -29,7 +29,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.AvatarImage
import com.ts3client.android.Picture
import com.ts3client.android.ServerImages
import com.ts3client.net.IconRepository
import kotlin.math.roundToInt
@@ -66,15 +66,17 @@ fun ServerIcon(id: Long, modifier: Modifier = Modifier, size: Dp = 16.dp) {
fun Avatar(uniqueId: String, flag: String, modifier: Modifier = Modifier, size: Dp = 64.dp) {
val images = LocalServerImages.current ?: return
val version by images.version.collectAsStateWithLifecycle()
val image = remember(flag, version) { images.avatar(uniqueId, flag) } ?: return
val painter = when (image) {
is AvatarImage.Still -> remember(image) { BitmapPainter(image.bitmap) }
is AvatarImage.Moving -> rememberMovingPainter(image.drawable)
}
Image(painter, "Avatar", modifier.size(size).clip(RoundedCornerShape(8.dp)))
val picture = remember(flag, version) { images.avatar(uniqueId, flag) } ?: return
Image(rememberPicturePainter(picture), "Avatar", modifier.size(size).clip(RoundedCornerShape(8.dp)))
}
/** Draws [picture], playing it while on screen if it moves. */
@Composable
fun rememberPicturePainter(picture: Picture): Painter = when (picture) {
is Picture.Still -> remember(picture) { BitmapPainter(picture.bitmap) }
is Picture.Moving -> rememberMovingPainter(picture.drawable)
}
/** Plays [drawable] while on screen, redrawing at each of its frames. */
@Composable
private fun rememberMovingPainter(drawable: AnimatedImageDrawable): Painter {
val painter = remember(drawable) { MovingPainter(drawable) }

View File

@@ -50,6 +50,11 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
"MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK }
Section("Appearance")
IconPackPicker(vm.icons)
var pictures by remember { mutableStateOf(vm.settings.descriptionPictures) }
Toggle("Show pictures in descriptions", pictures) {
pictures = it
vm.setDescriptionPictures(it)
}
}
}
}