Open avatars and description pictures full size on Android; fold minor client facts

A client's sheet now shows only their server and channel groups, with
the rest (contact, talk power, version, microphone, unique ID, ...)
behind "more…". Core marks those facts minor, for frontends short of
room; groups without a name or an icon are left out on both clients.

Tapping an avatar or a picture in a description opens it on black, to
pinch or double-tap zoom. Its menu saves it to Pictures/TeamSpeak, takes
it as our avatar (not on our own), copies its address, and opens the
link it sat in, which a tap used to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 08:27:50 +00:00
parent e799218444
commit 7f30a27d73
11 changed files with 349 additions and 24 deletions

View File

@@ -0,0 +1,39 @@
package com.ts3client.android
import android.content.ContentValues
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Environment
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** Puts pictures into the shared Pictures collection, in a TeamSpeak album, where galleries find them. */
object SavedPictures {
/** Saves [data] as [name], its extension following what the data is; false if it could not be. */
suspend fun save(context: Context, name: String, data: ByteArray): Boolean = withContext(Dispatchers.IO) {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(data, 0, data.size, bounds)
val mime = bounds.outMimeType ?: return@withContext false
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime)
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, if (extension == null) name else "$name.$extension")
put(MediaStore.Images.Media.MIME_TYPE, mime)
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TeamSpeak")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val resolver = context.contentResolver
val uri = resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values)
?: return@withContext false
try {
resolver.openOutputStream(uri)!!.use { it.write(data) }
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
true
} catch (e: Exception) {
resolver.delete(uri, null, null)
false
}
}
}

View File

@@ -16,8 +16,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* One server the app is on: its [ServerSession], and the snapshots the UI shows of it. Session
@@ -162,6 +166,19 @@ class ServerController internal constructor(
conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) }
}
/** Uploads [data] as our avatar; null once it is set, else why not. */
suspend fun setAvatar(data: ByteArray): String? {
if (!session.isConnected) return "Not connected"
val file = withContext(Dispatchers.IO) {
File.createTempFile("avatar", null, app.cacheDir).apply { writeBytes(data) }
}
return try {
suspendCoroutine { done -> conn.setAvatar(file) { done.resume(it) } }
} finally {
file.delete()
}
}
// ---- details ----
fun serverDetails(): Details? =

View File

@@ -39,6 +39,10 @@ class ServerImages internal constructor(private val conn: TeamspeakConnection) {
return picture
}
/** The avatar's file as the server keeps it; null while it has none or it is still downloading. */
fun avatarData(uniqueId: String, flag: String): ByteArray? =
if (flag.isEmpty()) null else conn.avatars.get(uniqueId, flag)?.data()
/** Decoded no larger than needed: avatars may be far bigger than they are ever shown. */
private fun decode(data: ByteArray, maxPx: Int): ImageBitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }

View File

@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
/** What the app shows of a connection and the local client on it. */
@@ -67,6 +68,7 @@ class SessionController(private val context: Context) {
val settings: Settings = Settings.load()
val icons = IconTheme(context, settings)
val pictures = WebPictures(context.cacheDir, settings)
internal val cacheDir: File get() = context.cacheDir
private val bookmarkStore = Bookmarks.load()
internal val identityStore = IdentityStore.load(settings)
val identities = IdentityManager(context, identityStore, settings, bookmarkStore) {

View File

@@ -48,6 +48,9 @@ class WebPictures(cacheDir: File, private val settings: Settings) {
return State.Shown(picture)
}
/** The picture's file as downloaded; null while it is not there. */
suspend fun data(url: String): ByteArray? = withContext(Dispatchers.IO) { downloads.get(url)?.readBytes() }
private companion object {
const val CACHED = 32
const val MAX_PX = 1024

View File

@@ -82,11 +82,13 @@ fun App(vm: SessionController) {
server != null && !connectOpen -> {
serverStates.SaveableStateProvider(server.id) {
CompositionLocalProvider(LocalServerImages provides server.images) {
PictureViewerHost(server) {
ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone,
onConnectOther = { connectOpen = true })
}
}
}
}
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, { openContacts(null) }, connectAsking,
onBack = if (current != null) ({ connectOpen = false }) else null)
}

View File

@@ -157,13 +157,17 @@ private fun WebPicture(image: Piece.Image, style: TextStyle, onLink: (String) ->
WebPictures.State.Broken -> PictureLink("BROKEN_IMAGE", image, style, onLink)
is WebPictures.State.Shown -> {
val picture = shown.picture
val viewer = LocalPictureViewer.current
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),
.then(if (viewer != null) Modifier.clickable {
val openLink = image.href?.let { href -> { onLink(href) } }
viewer(ViewedPicture(pictureName(image.url), image.url, openLink) { pictures.data(image.url) })
} else if (image.href != null) Modifier.clickable { onLink(image.href) } else Modifier),
)
}
}

View File

@@ -37,13 +37,23 @@ import androidx.compose.ui.unit.dp
import com.ts3client.net.Details
import com.ts3client.text.BBCode
/** The facts of the server, a channel or a client, then its description, for the top of its sheet. */
/**
* The facts of the server, a channel or a client, then its description, for the top of its sheet.
* The minor facts wait behind "more…".
*/
@Composable
fun DetailsSection(details: Details, onLink: (String) -> Unit, modifier: Modifier = Modifier) {
var all by rememberSaveable(details.title()) { mutableStateOf(false) }
val shown = if (all) details.fields() else details.fields().filterNot { it.minor() }
val more = !all && shown.size < details.fields().size
Column(modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (details.fields().isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
for (field in details.fields()) Fact(field)
if (shown.isNotEmpty() || more) {
Column(Modifier.animateContentSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
for (field in shown) Fact(field)
if (more) {
Text("more…", Modifier.clickable { all = true }.padding(vertical = 2.dp),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
}
}
}
when {
@@ -65,11 +75,14 @@ private fun Fact(field: Details.Field) {
} else {
// Each group with its icon, as TeamSpeak's info frame lists them.
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// A nameless group shows only its icon, and takes no comma.
val lastNamed = field.groups().indexOfLast { it.name.isNotBlank() }
field.groups().forEachIndexed { i, group ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
ServerIcon(group.iconId, size = 14.dp)
Text(group.name + if (i < field.groups().size - 1) "," else "",
style = MaterialTheme.typography.bodySmall)
if (group.name.isNotBlank()) {
Text(group.name.trim() + if (i < lastNamed) "," else "", style = MaterialTheme.typography.bodySmall)
}
}
}
}

View File

@@ -0,0 +1,220 @@
package com.ts3client.android.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.ts3client.android.Picture
import com.ts3client.android.SavedPictures
import com.ts3client.android.ServerController
import com.ts3client.android.decodePicture
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** A picture to open full size. */
class ViewedPicture(
/** What to call it when saved, without an extension. */
val name: String,
/** Where it is on the web; null for an avatar. */
val url: String? = null,
/** Follows the link it sits in; null when it sits in none. */
val openLink: (() -> Unit)? = null,
/** Our own avatar, which there is no taking. */
val ownAvatar: Boolean = false,
/** Its file; null when it is not there. */
val read: suspend () -> ByteArray?,
)
/** Opens a picture full size; null where nothing can. */
val LocalPictureViewer = staticCompositionLocalOf<((ViewedPicture) -> Unit)?> { null }
/** Lets [content] open pictures full size, over everything, with [server] to take one as our avatar on. */
@Composable
fun PictureViewerHost(server: ServerController, content: @Composable () -> Unit) {
var viewed by remember { mutableStateOf<ViewedPicture?>(null) }
CompositionLocalProvider(LocalPictureViewer provides { viewed = it }, content = content)
val context = LocalContext.current
viewed?.let { picture ->
PictureViewer(
picture,
onSteal = if (picture.ownAvatar) null else { data ->
val error = server.setAvatar(data)
Toast.makeText(context, error ?: "Avatar set.", Toast.LENGTH_SHORT).show()
},
onDismiss = { viewed = null },
)
}
}
/** A name to save the picture at [url] under: its file's, or "picture" when it has none. */
fun pictureName(url: String): String =
url.substringBefore('?').substringBefore('#').trimEnd('/').substringAfterLast('/')
.substringBeforeLast('.').ifBlank { "picture" }
/** The picture on black, filling the screen; pinch or double-tap to zoom, tap to hide the bar. */
@Composable
private fun PictureViewer(
picture: ViewedPicture,
/** Takes the picture as our avatar; null when it cannot be. */
onSteal: (suspend (ByteArray) -> Unit)?,
onDismiss: () -> Unit,
) {
val state by produceState<Shown?>(null, picture) {
val data = picture.read()
value = Shown(data, data?.let { withContext(Dispatchers.Default) { decodePicture(it, MAX_PX) } })
}
val data = state?.data
var bar by remember { mutableStateOf(true) }
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var size by remember { mutableStateOf(IntSize.Zero) }
fun panTo(target: Offset) {
val maxX = size.width * (scale - 1) / 2
val maxY = size.height * (scale - 1) / 2
offset = Offset(target.x.coerceIn(-maxX, maxX), target.y.coerceIn(-maxY, maxY))
}
Dialog(onDismiss, DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
Box(
Modifier.fillMaxSize().background(Color.Black).onSizeChanged { size = it }
.pointerInput(Unit) {
detectTapGestures(
onTap = { bar = !bar },
onDoubleTap = { scale = if (scale > 1f) 1f else DOUBLE_TAP_SCALE; panTo(Offset.Zero) },
)
}
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(1f, MAX_SCALE)
panTo(offset + pan)
}
},
contentAlignment = Alignment.Center,
) {
val shown = state?.picture
when {
state == null -> PackIcon("LOADING_IMAGE", "Loading picture")
shown == null -> PackIcon("BROKEN_IMAGE", "Broken picture")
else -> Image(
rememberPicturePainter(shown), null,
Modifier.fillMaxSize().graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
},
contentScale = ContentScale.Fit,
)
}
if (bar) {
CompositionLocalProvider(LocalContentColor provides Color.White) {
Bar(picture, data, onSteal, onDismiss, Modifier.align(Alignment.TopCenter))
}
}
}
}
}
@Composable
private fun Bar(
picture: ViewedPicture,
data: ByteArray?,
onSteal: (suspend (ByteArray) -> Unit)?,
onDismiss: () -> Unit,
modifier: Modifier,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var menu by remember { mutableStateOf(false) }
Row(
modifier.fillMaxWidth()
.background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.6f), Color.Transparent)))
.statusBarsPadding(),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onDismiss) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
Box(Modifier.weight(1f))
Box {
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "Picture options") }
DropdownMenu(menu, onDismissRequest = { menu = false }) {
DropdownMenuItem({ Text("Save") }, enabled = data != null, onClick = {
menu = false
val bytes = data ?: return@DropdownMenuItem
scope.launch {
val saved = SavedPictures.save(context, picture.name, bytes)
Toast.makeText(context, if (saved) "Saved." else "Could not save.", Toast.LENGTH_SHORT).show()
}
})
if (onSteal != null) {
DropdownMenuItem({ Text("Steal for avatar") }, enabled = data != null, onClick = {
menu = false
val bytes = data ?: return@DropdownMenuItem
scope.launch { onSteal(bytes) }
})
}
picture.url?.let { url ->
DropdownMenuItem({ Text("Copy URL") }, onClick = {
menu = false
context.getSystemService(ClipboardManager::class.java)
.setPrimaryClip(ClipData.newPlainText("URL", url))
})
}
picture.openLink?.let { open ->
DropdownMenuItem({ Text("Open link") }, onClick = {
menu = false
onDismiss()
open()
})
}
}
}
}
}
/** The picture's file, and the picture when the file is one; both null when it is not there. */
private class Shown(val data: ByteArray?, val picture: Picture?)
private const val MAX_PX = 4096
private const val MAX_SCALE = 8f
private const val DOUBLE_TAP_SCALE = 2.5f

View File

@@ -74,7 +74,13 @@ fun ClientSheet(
if (client.name != client.nickname) Text(client.nickname, style = MaterialTheme.typography.bodyMedium)
if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, style = MaterialTheme.typography.bodyMedium)
}
Avatar(client.uniqueId, client.avatarFlag)
val viewer = LocalPictureViewer.current
val images = LocalServerImages.current
Avatar(client.uniqueId, client.avatarFlag, Modifier.clickable(enabled = viewer != null && images != null) {
viewer?.invoke(ViewedPicture("avatar_${client.nickname}", ownAvatar = client.self) {
images?.avatarData(client.uniqueId, client.avatarFlag)
})
})
}
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
if (client.self) {

View File

@@ -23,10 +23,19 @@ public record Details(String title, List<Field> fields, String description, bool
*
* @param groups the groups {@code value} names, in order, for frontends that show their icons;
* empty when it names none
* @param minor for frontends short of room to show only on asking
*/
public record Field(String label, String value, List<Group> groups) {
public record Field(String label, String value, List<Group> groups, boolean minor) {
Field(String label, String value) {
this(label, value, List.of());
this(label, value, List.of(), false);
}
Field(String label, String value, List<Group> groups) {
this(label, value, groups, false);
}
static Field minor(String label, String value) {
return new Field(label, value, List.of(), true);
}
}
@@ -66,7 +75,7 @@ public record Details(String title, List<Field> fields, String description, bool
if (contact != null) {
String type = contact.type.label();
if (!contact.phoneticNickname.isEmpty()) type += " (" + contact.phoneticNickname + ")";
fields.add(new Field("Contact", type));
fields.add(Field.minor("Contact", type));
}
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
@@ -74,27 +83,33 @@ public record Details(String title, List<Field> fields, String description, bool
List<String> names = model.serverGroupNames(cl.serverGroupIds);
fields.add(new Field("Server groups", names.isEmpty() ? "—" : String.join(", ", names)));
} else {
// A nameless group is there for its icon only; without one, there is nothing to show of it.
List<Group> shown = new ArrayList<>();
List<String> names = new ArrayList<>();
for (Group g : serverGroups) names.add(g.name);
fields.add(new Field("Server groups", String.join(", ", names), serverGroups));
for (Group g : serverGroups) {
if (g.name.isBlank() && g.iconId == 0) continue;
shown.add(g);
if (!g.name.isBlank()) names.add(g.name.trim());
}
fields.add(new Field("Server groups", names.isEmpty() ? "—" : String.join(", ", names), shown));
}
Group channelGroup = model.channelGroup(cl.channelGroupId);
fields.add(channelGroup != null
? new Field("Channel group", channelGroup.name, List.of(channelGroup))
: new Field("Channel group", "#" + cl.channelGroupId));
if (cl.talkPower != 0) fields.add(new Field("Talk power", Integer.toString(cl.talkPower)));
if (cl.talkPower != 0) fields.add(Field.minor("Talk power", Integer.toString(cl.talkPower)));
if (!cl.version.isEmpty()) {
fields.add(new Field("Version", cl.version + (cl.platform.isEmpty() ? "" : " on " + cl.platform)));
fields.add(Field.minor("Version", cl.version + (cl.platform.isEmpty() ? "" : " on " + cl.platform)));
} else if (!cl.platform.isEmpty()) {
fields.add(new Field("Platform", cl.platform));
fields.add(Field.minor("Platform", cl.platform));
}
if (cl.away) fields.add(new Field("Status", "Away"));
if (!cl.inputHardware) fields.add(new Field("Microphone", "disabled"));
else if (cl.inputMuted) fields.add(new Field("Microphone", "muted"));
if (!cl.outputHardware) fields.add(new Field("Speakers", "disabled"));
else if (cl.outputMuted) fields.add(new Field("Speakers", "muted"));
if (!cl.uniqueId.isEmpty()) fields.add(new Field("Unique ID", cl.uniqueId));
if (cl.away) fields.add(Field.minor("Status", "Away"));
if (!cl.inputHardware) fields.add(Field.minor("Microphone", "disabled"));
else if (cl.inputMuted) fields.add(Field.minor("Microphone", "muted"));
if (!cl.outputHardware) fields.add(Field.minor("Speakers", "disabled"));
else if (cl.outputMuted) fields.add(Field.minor("Speakers", "muted"));
if (!cl.uniqueId.isEmpty()) fields.add(Field.minor("Unique ID", cl.uniqueId));
String name = contact == null ? cl.nickname : contact.displayName(cl.nickname);
return new Details(name, fields, cl.description == null ? "" : cl.description, false);