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,103 @@
package com.ts3client.net;
import com.github.manevolent.ts3j.command.SingleCommand;
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
import com.github.manevolent.ts3j.protocol.ProtocolRole;
import com.ts3client.net.avatar.AvatarNaming;
import com.ts3client.net.avatar.ImageFormat;
import com.ts3client.net.filetransfer.FileTransfer;
import com.ts3client.net.filetransfer.FileTransferManager;
import java.io.File;
import java.nio.file.Files;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Publishes and withdraws the local client's avatar, the way the official client does
* it: the picture goes into channel 0's file repository as {@code /avatar_<name>} and
* {@code client_flag_avatar} is then set to its MD5, which is what other clients key
* their downloads on.
*/
final class AvatarAdmin {
/** The server's own default for {@code i_client_max_avatar_filesize}. */
static final int DEFAULT_MAX_AVATAR_BYTES = 200_000;
/** The largest avatar we are willing to download, whatever a server allows. */
static final int MAX_AVATAR_DOWNLOAD_BYTES = 8 * 1024 * 1024;
private static final long UPLOAD_TIMEOUT_MS = 60_000;
private final TeamspeakConnection conn;
AvatarAdmin(TeamspeakConnection conn) {
this.conn = conn;
}
/**
* How large an avatar this server lets us upload, from
* {@code i_client_max_avatar_filesize}, or the server default while unknown.
*/
int maxUploadBytes() {
int limit = conn.getModel().selfPermissionValue("i_client_max_avatar_filesize");
return limit > 0 ? limit : DEFAULT_MAX_AVATAR_BYTES;
}
/** Uploads {@code source} as our avatar and announces it; returns its flag. */
String upload(File source) throws Exception {
byte[] data = Files.readAllBytes(source.toPath());
if (data.length == 0) throw new IllegalArgumentException("That file is empty");
if (ImageFormat.detect(data) == null) {
throw new IllegalArgumentException("Avatars must be PNG, JPEG, GIF or BMP images");
}
int limit = maxUploadBytes();
if (data.length > limit) {
throw new IllegalArgumentException("This server limits avatars to " + (limit / 1024) + " KiB");
}
String name = ownFileName();
CountDownLatch done = new CountDownLatch(1);
FileTransfer transfer = fileTransfers().upload(0, "", "/" + name, source, true, t -> {
if (t.isDone()) done.countDown();
});
if (!done.await(UPLOAD_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("The upload did not finish in time");
}
if (transfer.getState() != FileTransfer.State.COMPLETED) {
throw new IllegalStateException(transfer.getErrorMessage() == null
? "The upload failed" : transfer.getErrorMessage());
}
String flag = AvatarNaming.flag(data);
setFlag(flag);
conn.getAvatars().put(data);
return flag;
}
/** Clears our avatar flag and removes the file from the server. */
void remove() throws Exception {
setFlag("");
try {
fileTransfers().delete(0, "", "/" + ownFileName());
} catch (Exception ignored) {
// The flag is what other clients go by; a leftover file only wastes space.
}
}
private void setFlag(String flag) throws Exception {
SingleCommand cmd = new SingleCommand("clientupdate", ProtocolRole.CLIENT,
new CommandSingleParameter("client_flag_avatar", flag));
conn.socket().executeCommand(cmd).complete();
}
private String ownFileName() {
String name = AvatarNaming.fileName(conn.getSelfUniqueId());
if (name == null) throw new IllegalStateException("Our identity has no usable unique id");
return name;
}
private FileTransferManager fileTransfers() {
FileTransferManager ft = conn.fileTransfers();
if (ft == null) throw new IllegalStateException("Not connected");
return ft;
}
}

View File

@@ -18,6 +18,8 @@ public final class ClientEntry {
public String version = "";
public long idleTimeMs;
public String description = "";
/** MD5 of the client's avatar ({@code client_flag_avatar}); empty when they have none. */
public String avatarFlag = "";
public boolean talking;
public boolean inputMuted; // microphone muted (client_input_muted)

View File

@@ -45,6 +45,7 @@ final class ConnectionEventHandler implements TS3Listener {
c.away = e.isClientAway();
c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message"));
c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier());
c.avatarFlag = TeamspeakConnection.orEmpty(e.get("client_flag_avatar"));
c.databaseId = e.getClientDatabaseId();
c.serverGroupIds = parseIntList(e.getClientServerGroups());
c.channelGroupId = e.getClientChannelGroupId();
@@ -321,6 +322,7 @@ final class ConnectionEventHandler implements TS3Listener {
}
if (has(e, "client_is_channel_commander"))
c.channelCommander = e.getBoolean("client_is_channel_commander");
if (has(e, "client_flag_avatar")) c.avatarFlag = TeamspeakConnection.orEmpty(e.get("client_flag_avatar"));
announceClientUpdate(e, c, renamed, oldName);
conn.ui.onModelChanged();
}

View File

@@ -19,6 +19,9 @@ import com.ts3client.chatlog.ChatLogStore;
import com.ts3client.config.Settings;
import com.ts3client.contacts.Contact;
import com.ts3client.contacts.ContactStore;
import com.ts3client.net.avatar.Avatar;
import com.ts3client.net.avatar.AvatarNaming;
import com.ts3client.net.avatar.AvatarRepository;
import com.ts3client.net.filetransfer.FileTransfer;
import com.ts3client.net.filetransfer.FileTransferManager;
import com.ts3client.net.ban.BanEntry;
@@ -66,6 +69,7 @@ public final class TeamspeakConnection implements TS3Listener {
/** Package-private: read directly by {@link ConnectionEventHandler}. */
final ConnectionListener ui;
private final IconRepository icons;
private final AvatarRepository avatars;
/** Package-private: read directly by {@link ConnectionEventHandler}. */
final SoundNotifier sounds;
/** Friends, blocked clients and their per-client filters; also read by {@link ConnectionEventHandler}. */
@@ -78,6 +82,7 @@ public final class TeamspeakConnection implements TS3Listener {
final ChannelAdmin channels = new ChannelAdmin(this);
/** The server's ban list; also package-private for {@link ConnectionEventHandler}. */
final BanAdmin bans = new BanAdmin(this);
private final AvatarAdmin avatarAdmin = new AvatarAdmin(this);
/** Package-private: read directly by {@link ConnectionStatsCollector}. */
LocalTeamspeakClientSocket client;
@@ -123,6 +128,7 @@ public final class TeamspeakConnection implements TS3Listener {
this.sounds = sounds;
this.contacts = contacts;
this.icons = new IconRepository(this::fetchIcon, ui::onIconsUpdated);
this.avatars = new AvatarRepository(this::fetchAvatar, ui::onInfoUpdated);
}
public ServerModel getModel() {
@@ -299,6 +305,7 @@ public final class TeamspeakConnection implements TS3Listener {
fileTransfers = new FileTransferManager(client, () -> serverHost);
client.setMicrophone(microphone);
icons.retryFailed();
avatars.retryFailed();
connected = true;
connectedAtMs = System.currentTimeMillis();
leaving = false;
@@ -472,6 +479,7 @@ public final class TeamspeakConnection implements TS3Listener {
e.away = cl.isAway();
e.awayMessage = orEmpty(cl.get("client_away_message"));
e.uniqueId = cl.getUniqueIdentifier();
e.avatarFlag = orEmpty(cl.get("client_flag_avatar"));
e.serverGroupIds = cl.getServerGroups();
e.channelGroupId = cl.getChannelGroupId();
e.self = (cl.getId() == selfClientId);
@@ -912,6 +920,7 @@ public final class TeamspeakConnection implements TS3Listener {
e.version = orEmpty(c.getVersion());
e.idleTimeMs = c.getIdleTime();
e.description = orEmpty(c.get("client_description"));
e.avatarFlag = orEmpty(c.get("client_flag_avatar"));
int[] groups = c.getServerGroups();
if (groups != null && groups.length > 0) e.serverGroupIds = groups;
e.channelGroupId = c.getChannelGroupId();
@@ -1232,6 +1241,55 @@ public final class TeamspeakConnection implements TS3Listener {
return ft.downloadToMemory(0, "", "/icon_" + iconId, MAX_ICON_BYTES);
}
// ---- avatars ----
/** Client avatars for this server, downloaded on demand. */
public AvatarRepository getAvatars() {
return avatars;
}
/** The avatar a client currently shows, or {@code null} if none or not yet available. */
public Avatar avatarOf(ClientEntry client) {
return client == null ? null : avatars.get(client.uniqueId, client.avatarFlag);
}
/** How large an avatar this server lets us upload, in bytes. */
public int maxAvatarBytes() {
return avatarAdmin.maxUploadBytes();
}
/**
* Uploads an image as our avatar and publishes it to the other clients.
*
* @param callback given {@code null} on success, or the failure message
*/
public void setAvatar(File source, Consumer<String> callback) {
run("ts3j-avatar-upload", (flag, error) -> {
if (error == null) updateSelf(self -> self.avatarFlag = flag);
callback.accept(error);
}, () -> avatarAdmin.upload(source));
}
/** Withdraws our avatar; {@code callback} is given {@code null} on success. */
public void removeAvatar(Consumer<String> callback) {
run("ts3j-avatar-remove", (ignored, error) -> {
if (error == null) updateSelf(self -> self.avatarFlag = "");
callback.accept(error);
}, () -> {
avatarAdmin.remove();
return null;
});
}
/** Reads a client's avatar out of the virtual server's file repository (channel 0). */
private byte[] fetchAvatar(String uniqueId) throws Exception {
FileTransferManager ft = fileTransfers;
if (ft == null) throw new IllegalStateException("Not connected");
String name = AvatarNaming.fileName(uniqueId);
if (name == null) throw new IllegalArgumentException("Not a client unique id: " + uniqueId);
return ft.downloadToMemory(0, "", "/" + name, AvatarAdmin.MAX_AVATAR_DOWNLOAD_BYTES);
}
// ---- file transfers ----
/** Whether a live connection capable of file transfers is available. */

View File

@@ -0,0 +1,17 @@
package com.ts3client.net.avatar;
import java.io.File;
/**
* A downloaded avatar: its bytes, container format and the on-disk copy for views that
* can only reference images by URL.
*
* @param flag the MD5 the server advertises for it ({@code client_flag_avatar})
* @param file the cached copy, or {@code null} if the disk cache is unavailable
*/
public record Avatar(String flag, byte[] data, ImageFormat format, File file) {
public boolean isAnimated() {
return format == ImageFormat.GIF;
}
}

View File

@@ -0,0 +1,67 @@
package com.ts3client.net.avatar;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
/**
* How TeamSpeak names and identifies avatars.
*
* <p>An avatar lives in the virtual server's file repository (channel 0) as
* {@code /avatar_<name>}, where the name is the client's unique identifier — the
* base64 of a 20-byte SHA-1 — decoded and re-encoded as forty letters {@code a}–{@code p}
* (one per nibble, high nibble first). This is what the client library calls
* {@code identityStringToFilename}, recovered from the official client.
*
* <p>The {@code client_flag_avatar} property carries the MD5 of the file's bytes, so a
* changed avatar is detectable without re-downloading.
*/
public final class AvatarNaming {
private static final char[] NIBBLES = "abcdefghijklmnop".toCharArray();
private AvatarNaming() {
}
/**
* @return the repository file name ({@code avatar_…}) of a client's avatar, without
* the leading slash, or {@code null} if the unique id is not a valid one
*/
public static String fileName(String uniqueId) {
byte[] hash;
try {
hash = Base64.getDecoder().decode(uniqueId.trim());
} catch (IllegalArgumentException | NullPointerException e) {
return null;
}
if (hash.length != 20) return null;
StringBuilder sb = new StringBuilder("avatar_");
for (byte b : hash) {
sb.append(NIBBLES[(b >> 4) & 0xF]).append(NIBBLES[b & 0xF]);
}
return sb.toString();
}
/** The lowercase hex MD5 that TeamSpeak publishes as {@code client_flag_avatar}. */
public static String flag(byte[] avatar) {
try {
byte[] digest = MessageDigest.getInstance("MD5").digest(avatar);
StringBuilder sb = new StringBuilder(32);
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
/** Whether the flag looks like an MD5 (the server also passes junk through unchanged). */
public static boolean isFlag(String flag) {
if (flag == null || flag.length() != 32) return false;
for (byte c : flag.getBytes(StandardCharsets.US_ASCII)) {
boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!hex) return false;
}
return true;
}
}

View File

@@ -0,0 +1,148 @@
package com.ts3client.net.avatar;
import com.ts3client.config.Settings;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Supplies client avatars, downloading each from the server's file repository once
* and caching it in memory and on disk.
*
* <p>Avatars are identified by their {@code client_flag_avatar} MD5 rather than by
* client, so a client changing their picture simply asks for a new flag, and two
* clients with the same picture share one download. A file whose MD5 does not match
* the advertised flag is discarded, as the official client does.
*
* <p>Lookups never block: {@link #get} returns {@code null} while an avatar is still
* being fetched and the listener is notified once it becomes available.
*/
public final class AvatarRepository {
/** Downloads {@code /avatar_<name>} of the given client from channel 0. */
public interface Fetcher {
byte[] fetchAvatar(String uniqueId) throws Exception;
}
private final Fetcher fetcher;
private final Runnable onAvatarLoaded;
private final File cacheDir;
private final Map<String, Avatar> avatars = new ConcurrentHashMap<>();
private final Set<String> pending = ConcurrentHashMap.newKeySet();
private final Set<String> failed = ConcurrentHashMap.newKeySet();
private final ExecutorService downloads = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "ts3j-avatars");
t.setDaemon(true);
return t;
});
/**
* @param onAvatarLoaded run (off the UI thread) whenever a previously missing
* avatar has arrived, so the view can repaint
*/
public AvatarRepository(Fetcher fetcher, Runnable onAvatarLoaded) {
this(fetcher, onAvatarLoaded, new File(Settings.configDir(), "avatars"));
}
/** @param cacheDir where downloaded avatars are kept between sessions */
public AvatarRepository(Fetcher fetcher, Runnable onAvatarLoaded, File cacheDir) {
this.fetcher = fetcher;
this.onAvatarLoaded = onAvatarLoaded;
this.cacheDir = cacheDir;
}
/**
* @param uniqueId the client whose avatar is wanted
* @param flag the MD5 the client currently advertises; empty means no avatar
* @return the avatar, or {@code null} if the client has none, it is still loading
* or it is unavailable. An avatar that is not cached yet starts downloading.
*/
public Avatar get(String uniqueId, String flag) {
if (!AvatarNaming.isFlag(flag)) return null;
String key = flag.toLowerCase();
Avatar cached = avatars.get(key);
if (cached != null) return cached;
Avatar fromDisk = readDiskCache(key);
if (fromDisk != null) {
avatars.put(key, fromDisk);
return fromDisk;
}
if (uniqueId != null && !uniqueId.isEmpty() && !failed.contains(key) && pending.add(key)) {
downloads.execute(() -> download(uniqueId, key));
}
return null;
}
/** Records an avatar we just uploaded ourselves, so it shows without a round trip. */
public Avatar put(byte[] data) {
String flag = AvatarNaming.flag(data);
Avatar avatar = new Avatar(flag, data, ImageFormat.detect(data), writeDiskCache(flag, data));
avatars.put(flag, avatar);
failed.remove(flag);
return avatar;
}
/** Forgets download failures so avatars can be retried on the next reconnect. */
public void retryFailed() {
failed.clear();
}
public void shutdown() {
downloads.shutdownNow();
}
private void download(String uniqueId, String flag) {
try {
byte[] data = fetcher.fetchAvatar(uniqueId);
if (data == null || data.length == 0 || !AvatarNaming.flag(data).equals(flag)) {
failed.add(flag);
return;
}
avatars.put(flag, new Avatar(flag, data, ImageFormat.detect(data), writeDiskCache(flag, data)));
if (onAvatarLoaded != null) onAvatarLoaded.run();
} catch (IllegalStateException e) {
// Not connected (yet); leave it retryable.
} catch (Exception e) {
// Missing or permission-denied avatars are common; don't ask again.
failed.add(flag);
} finally {
pending.remove(flag);
}
}
private Avatar readDiskCache(String flag) {
for (ImageFormat format : ImageFormat.values()) {
File file = new File(cacheDir, flag + "." + format.extension);
if (!file.isFile()) continue;
try {
byte[] data = Files.readAllBytes(file.toPath());
if (AvatarNaming.flag(data).equals(flag)) return new Avatar(flag, data, format, file);
} catch (IOException ignored) {
// fall through and re-download
}
}
return null;
}
private File writeDiskCache(String flag, byte[] data) {
ImageFormat format = ImageFormat.detect(data);
if (format == null) return null; // nothing we could display later anyway
File file = new File(cacheDir, flag + "." + format.extension);
try {
//noinspection ResultOfMethodCallIgnored
cacheDir.mkdirs();
Files.write(file.toPath(), data);
return file;
} catch (IOException e) {
return null; // the in-memory cache still serves this session
}
}
}

View File

@@ -0,0 +1,22 @@
package com.ts3client.net.avatar;
/** The image container formats TeamSpeak accepts for avatars, told apart by their magic bytes. */
public enum ImageFormat {
PNG("png"), JPEG("jpg"), GIF("gif"), BMP("bmp");
public final String extension;
ImageFormat(String extension) {
this.extension = extension;
}
/** @return the format of {@code data}, or {@code null} if it is none of the supported ones */
public static ImageFormat detect(byte[] data) {
if (data == null || data.length < 6) return null;
if ((data[0] & 0xFF) == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G') return PNG;
if ((data[0] & 0xFF) == 0xFF && (data[1] & 0xFF) == 0xD8) return JPEG;
if (data[0] == 'G' && data[1] == 'I' && data[2] == 'F' && data[3] == '8') return GIF;
if (data[0] == 'B' && data[1] == 'M') return BMP;
return null;
}
}

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));
}
}