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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user