Show client avatars and let the user set their own

Avatars are fetched from the server's file repository as /avatar_<id>,
verified against client_flag_avatar and cached on disk; animated GIFs play in
the info panel. Self > Set avatar... uploads or removes our own picture within
the server's size limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 19:03:00 +00:00
parent dbb425403a
commit 2a73900dcc
20 changed files with 886 additions and 5 deletions

View File

@@ -0,0 +1,39 @@
package com.ts3client.net.avatar;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AvatarNamingTest {
@Test
void fileNameEncodesDecodedUidNibbleWise() {
// base64 of 0x00 0x01 ... 0x13
String uid = "AAECAwQFBgcICQoLDA0ODxAREhM=";
assertEquals("avatar_aaabacadaeafagahaiajakalamanaoapbabbbcbd", AvatarNaming.fileName(uid));
assertEquals("avatar_" + "p".repeat(40),
AvatarNaming.fileName("//////////////////////////8="));
}
@Test
void fileNameRejectsMalformedIds() {
assertNull(AvatarNaming.fileName(""));
assertNull(AvatarNaming.fileName("not base64!"));
assertNull(AvatarNaming.fileName("AAEC")); // decodes, but not to 20 bytes
assertNull(AvatarNaming.fileName(null));
}
@Test
void flagIsLowercaseMd5() {
assertEquals("9e107d9d372bb6826bd81d3542a419d6",
AvatarNaming.flag("The quick brown fox jumps over the lazy dog".getBytes(StandardCharsets.US_ASCII)));
assertTrue(AvatarNaming.isFlag("9e107d9d372bb6826bd81d3542a419d6"));
assertFalse(AvatarNaming.isFlag(""));
assertFalse(AvatarNaming.isFlag("zz107d9d372bb6826bd81d3542a419d6"));
}
}

View File

@@ -0,0 +1,83 @@
package com.ts3client.net.avatar;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AvatarRepositoryTest {
private static final byte[] GIF = "GIF89a-not-really-an-image".getBytes();
private static final String UID = "AAECAwQFBgcICQoLDA0ODxAREhM=";
@TempDir
File cache;
@Test
void downloadsOnceThenServesFromMemoryAndDisk() throws Exception {
AtomicInteger fetches = new AtomicInteger();
CountDownLatch loaded = new CountDownLatch(1);
AvatarRepository repo = new AvatarRepository(uid -> {
fetches.incrementAndGet();
return GIF;
}, loaded::countDown, cache);
String flag = AvatarNaming.flag(GIF);
assertNull(repo.get(UID, flag));
assertTrue(loaded.await(5, TimeUnit.SECONDS));
Avatar avatar = repo.get(UID, flag);
assertNotNull(avatar);
assertEquals(ImageFormat.GIF, avatar.format());
assertTrue(avatar.isAnimated());
assertEquals(new File(cache, flag + ".gif"), avatar.file());
assertEquals(1, fetches.get());
// A fresh repository over the same directory needs no download.
AvatarRepository again = new AvatarRepository(uid -> {
throw new AssertionError("should not fetch");
}, null, cache);
assertNotNull(again.get(UID, flag));
}
@Test
void rejectsFilesWhoseMd5DoesNotMatchTheFlag() throws Exception {
AtomicInteger fetches = new AtomicInteger();
AvatarRepository repo = new AvatarRepository(uid -> {
fetches.incrementAndGet();
return GIF;
}, null, cache);
String wrongFlag = "0123456789abcdef0123456789abcdef";
assertNull(repo.get(UID, wrongFlag));
Thread.sleep(300);
assertNull(repo.get(UID, wrongFlag));
Thread.sleep(300);
assertEquals(1, fetches.get(), "a mismatch is not retried");
assertEquals(0, cache.list().length);
}
@Test
void ignoresClientsWithoutAnAvatar() {
AvatarRepository repo = new AvatarRepository(uid -> {
throw new AssertionError("should not fetch");
}, null, cache);
assertNull(repo.get(UID, ""));
assertNull(repo.get(UID, null));
}
@Test
void putMakesAnUploadedAvatarAvailableImmediately() {
AvatarRepository repo = new AvatarRepository(uid -> null, null, cache);
Avatar avatar = repo.put(GIF);
assertEquals(AvatarNaming.flag(GIF), avatar.flag());
assertEquals(avatar, repo.get(UID, avatar.flag()));
}
}

View File

@@ -0,0 +1,20 @@
package com.ts3client.net.avatar;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class ImageFormatTest {
@Test
void detectsByMagicBytes() {
assertEquals(ImageFormat.PNG, ImageFormat.detect(new byte[]{(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10}));
assertEquals(ImageFormat.JPEG, ImageFormat.detect(new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0, 0, 16}));
assertEquals(ImageFormat.GIF, ImageFormat.detect("GIF89a".getBytes()));
assertEquals(ImageFormat.BMP, ImageFormat.detect("BM\0\0\0\0".getBytes()));
assertNull(ImageFormat.detect("<svg/>".getBytes()));
assertNull(ImageFormat.detect(new byte[3]));
assertNull(ImageFormat.detect(null));
}
}