diff --git a/android/app/src/main/java/com/ts3client/android/SavedPictures.kt b/android/app/src/main/java/com/ts3client/android/SavedPictures.kt new file mode 100644 index 0000000..0a250c8 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/SavedPictures.kt @@ -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 + } + } +} diff --git a/android/app/src/main/java/com/ts3client/android/ServerController.kt b/android/app/src/main/java/com/ts3client/android/ServerController.kt index 11589f4..ebe16b1 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerController.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerController.kt @@ -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? = diff --git a/android/app/src/main/java/com/ts3client/android/ServerImages.kt b/android/app/src/main/java/com/ts3client/android/ServerImages.kt index 1cd8f9e..a4a726e 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerImages.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerImages.kt @@ -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 } diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index 6cf6ffe..dc1c8ad 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -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) { diff --git a/android/app/src/main/java/com/ts3client/android/WebPictures.kt b/android/app/src/main/java/com/ts3client/android/WebPictures.kt index d3c582e..4c68cd5 100644 --- a/android/app/src/main/java/com/ts3client/android/WebPictures.kt +++ b/android/app/src/main/java/com/ts3client/android/WebPictures.kt @@ -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 diff --git a/android/app/src/main/java/com/ts3client/android/ui/App.kt b/android/app/src/main/java/com/ts3client/android/ui/App.kt index cb639a8..37771f7 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/App.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/App.kt @@ -82,8 +82,10 @@ fun App(vm: SessionController) { server != null && !connectOpen -> { serverStates.SaveableStateProvider(server.id) { CompositionLocalProvider(LocalServerImages provides server.images) { - ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone, - onConnectOther = { connectOpen = true }) + PictureViewerHost(server) { + ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone, + onConnectOther = { connectOpen = true }) + } } } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt b/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt index 2f9a62c..0763b4b 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt @@ -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), ) } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt b/android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt index 4c6c5d3..aa8e1c1 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt @@ -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) + } } } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/PictureViewer.kt b/android/app/src/main/java/com/ts3client/android/ui/PictureViewer.kt new file mode 100644 index 0000000..67c7930 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/PictureViewer.kt @@ -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(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(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 diff --git a/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt index d2ff9b9..b952af4 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt @@ -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) { diff --git a/ts3-client/core/src/main/java/com/ts3client/net/Details.java b/ts3-client/core/src/main/java/com/ts3client/net/Details.java index 4fce731..818d3d5 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/Details.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/Details.java @@ -23,10 +23,19 @@ public record Details(String title, List 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 groups) { + public record Field(String label, String value, List 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 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 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 serverGroups = model.serverGroupsOf(cl.serverGroupIds); @@ -74,27 +83,33 @@ public record Details(String title, List fields, String description, bool List 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 shown = new ArrayList<>(); List 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);