Add the contact list (friends, blocked, neutral)

Mirrors the TeamSpeak 3 client's Contacts: clients can be filed as friend,
blocked or neutral from their context menu or by dragging them into the
Contacts window, given a custom and phonetic nickname, shown under either
name or both, and per contact muted automatically, ignored in server/channel
or private chat and pokes, have their away message hidden and whispers
allowed or denied. Friends are green and blocked clients red in the tree
(optional), new contacts start from per-type defaults ("Set Defaults"), and
the global whisper policy lives in Options -> Contacts. "Change Volume..."
adds a per-client dB gain that a contact entry remembers.

Contacts are stored in ~/.ts3jclient/contacts.txt using the exact record
format the official client keeps in the Contacts table of its settings.db
(field names and codes recovered from the binary), so entries move between
the two verbatim. A small read-only pure-Java SQLite reader imports the
official client's list on first run and on demand, without a driver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 07:50:58 +00:00
parent 22e0304f25
commit f409f500fd
33 changed files with 2474 additions and 23 deletions

View File

@@ -27,6 +27,11 @@ public interface VoiceOutput {
boolean isClientMuted(int clientId);
/** Scales one client's voice on top of the master volume; 1.0 is unchanged, the default. */
void setClientVolume(int clientId, double gain);
double getClientVolume(int clientId);
void setOutputDevice(String device);
void removeClient(int clientId);

View File

@@ -1,5 +1,6 @@
package com.ts3client.config;
import com.ts3client.contacts.ContactDefaults;
import com.ts3client.sound.NotificationSettings;
import java.io.File;
@@ -43,6 +44,26 @@ public final class Settings {
HYBRID
}
/** Who may whisper to us, the way TeamSpeak's whisper options put it. */
public enum WhisperPolicy {
/** Use individual settings in contacts, else allow. */
ALLOW_UNLESS_DENIED("Use individual settings in contacts, else allow"),
/** Use individual settings in contacts, else deny. */
DENY_UNLESS_ALLOWED("Use individual settings in contacts, else deny"),
/** Nobody is allowed to whisper to you; individual contact settings are ignored. */
NOBODY("Nobody");
private final String label;
WhisperPolicy(String label) {
this.label = label;
}
public String label() {
return label;
}
}
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
private static final File FILE = new File(DIR, "settings.properties");
@@ -149,6 +170,14 @@ public final class Settings {
/** Which actions make a sound, and which ones are important enough to survive muting. */
public final NotificationSettings notifications = new NotificationSettings();
// ---- contacts ----
/** What a client starts out with when added to the contacts as a friend, blocked or neutral. */
public final ContactDefaults contactDefaults = new ContactDefaults();
/** Colour friends and blocked clients in the server tree, as the TeamSpeak client does. */
public boolean colorContacts = true;
/** Whose whispers are played. */
public WhisperPolicy whisperPolicy = WhisperPolicy.ALLOW_UNLESS_DENIED;
public static Settings load() {
Settings s = new Settings();
try {
@@ -240,6 +269,9 @@ public final class Settings {
banHighlightOwn = parseB(props.getProperty("banHighlightOwn"), banHighlightOwn);
banListColumns = props.getProperty("banListColumns", banListColumns);
notifications.load(props);
contactDefaults.load(props);
colorContacts = parseB(props.getProperty("colorContacts"), colorContacts);
whisperPolicy = parseEnum(WhisperPolicy.class, props.getProperty("whisperPolicy"), whisperPolicy);
}
private void writeToProps() {
@@ -285,6 +317,18 @@ public final class Settings {
props.setProperty("banHighlightOwn", Boolean.toString(banHighlightOwn));
props.setProperty("banListColumns", banListColumns);
notifications.store(props);
contactDefaults.store(props);
props.setProperty("colorContacts", Boolean.toString(colorContacts));
props.setProperty("whisperPolicy", whisperPolicy.name());
}
private static <E extends Enum<E>> E parseEnum(Class<E> type, String v, E def) {
if (v == null) return def;
try {
return Enum.valueOf(type, v.trim());
} catch (IllegalArgumentException e) {
return def;
}
}
private static InputMode parseMode(String v, InputMode def) {

View File

@@ -0,0 +1,199 @@
package com.ts3client.contacts;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
/**
* One entry of the contact list, keyed by the client's unique id.
*
* <p>The fields are exactly the ones the TeamSpeak 3 client keeps for a contact,
* and {@link #toRecord()} / {@link #parseRecord} use its own {@code Key=Value}
* serialisation (the {@code value} column of its {@code Contacts} table), so
* records can be exchanged with it as they are.
*/
public final class Contact {
/** TeamSpeak writes {@code LastSeen} as an ISO local date-time without zone. */
private static final DateTimeFormatter LAST_SEEN = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
public final String uniqueId;
/** Custom name; TeamSpeak seeds it with the nickname the client had when added. */
public String nickname = "";
public String phoneticNickname = "";
public ContactType type = ContactType.NEUTRAL;
public boolean automute;
public boolean ignorePublicMessages;
public boolean ignorePrivateMessages;
public boolean ignorePokes;
public boolean ignoreAvatar;
public boolean ignoreAwayMessage;
public NicknameDisplay nicknameDisplay = NicknameDisplay.BOTH;
public boolean hasVolumeModifier;
/** Playback gain for this client in dB, applied when {@link #hasVolumeModifier} is set. */
public double volumeModifier;
public boolean whisperAllowed = true;
/** When the contact was last seen online, or {@code null} for never. */
public LocalDateTime lastSeen;
public String lastSeenServerName = "";
public String lastSeenServerAddress = "";
public Contact(String uniqueId) {
this.uniqueId = uniqueId == null ? "" : uniqueId;
}
/** The name the contact is shown under, given the nickname it currently carries. */
public String displayName(String currentNickname) {
return nicknameDisplay.render(nickname, currentNickname);
}
/** Playback gain as a linear multiplier, 1.0 when no modifier is set. */
public double volumeGain() {
return hasVolumeModifier ? Math.pow(10.0, volumeModifier / 20.0) : 1.0;
}
public Contact copy() {
Contact c = new Contact(uniqueId);
c.copyFrom(this);
return c;
}
/** Takes over every field but the unique id. */
public void copyFrom(Contact other) {
nickname = other.nickname;
phoneticNickname = other.phoneticNickname;
type = other.type;
automute = other.automute;
ignorePublicMessages = other.ignorePublicMessages;
ignorePrivateMessages = other.ignorePrivateMessages;
ignorePokes = other.ignorePokes;
ignoreAvatar = other.ignoreAvatar;
ignoreAwayMessage = other.ignoreAwayMessage;
nicknameDisplay = other.nicknameDisplay;
hasVolumeModifier = other.hasVolumeModifier;
volumeModifier = other.volumeModifier;
whisperAllowed = other.whisperAllowed;
lastSeen = other.lastSeen;
lastSeenServerName = other.lastSeenServerName;
lastSeenServerAddress = other.lastSeenServerAddress;
}
/** Applies the per-type defaults, as done when a client is first added. */
public void applyDefaults(ContactDefaults.Entry defaults) {
nicknameDisplay = defaults.nicknameDisplay;
automute = defaults.automute;
ignorePublicMessages = defaults.ignorePublicMessages;
ignorePrivateMessages = defaults.ignorePrivateMessages;
ignorePokes = defaults.ignorePokes;
ignoreAvatar = defaults.ignoreAvatar;
ignoreAwayMessage = defaults.ignoreAwayMessage;
whisperAllowed = !defaults.ignoreWhisper;
}
// ---- TeamSpeak record format ----
/** The contact as TeamSpeak's {@code Key=Value} lines, in the order it writes them. */
public String toRecord() {
StringBuilder sb = new StringBuilder();
line(sb, "IDS", uniqueId);
line(sb, "Nickname", nickname);
line(sb, "Friend", Integer.toString(type.code()));
line(sb, "Automute", flag(automute));
line(sb, "IgnorePublicMessages", flag(ignorePublicMessages));
line(sb, "IgnorePrivateMessages", flag(ignorePrivateMessages));
line(sb, "IgnorePokes", flag(ignorePokes));
line(sb, "IgnoreAvatar", flag(ignoreAvatar));
line(sb, "IgnoreAwayMessage", flag(ignoreAwayMessage));
line(sb, "NickShowType", Integer.toString(nicknameDisplay.code()));
line(sb, "HaveVolumeModifier", flag(hasVolumeModifier));
line(sb, "VolumeModifier", formatVolume(volumeModifier));
line(sb, "WhisperAllow", flag(whisperAllowed));
line(sb, "PhoneticNickname", phoneticNickname);
line(sb, "LastSeen", lastSeen == null ? "" : LAST_SEEN.format(lastSeen));
line(sb, "LastSeenServerName", lastSeenServerName);
line(sb, "LastSeenServerAddress", lastSeenServerAddress);
return sb.toString();
}
/** Parses a TeamSpeak contact record, or returns {@code null} when it carries no unique id. */
public static Contact parseRecord(String record) {
if (record == null) return null;
Map<String, String> fields = new LinkedHashMap<>();
for (String line : record.split("\r?\n")) {
int eq = line.indexOf('=');
if (eq > 0) fields.put(line.substring(0, eq).trim(), line.substring(eq + 1));
}
String uid = fields.getOrDefault("IDS", "").trim();
if (uid.isEmpty()) return null;
Contact c = new Contact(uid);
c.nickname = fields.getOrDefault("Nickname", "");
c.type = ContactType.fromCode(parseInt(fields.get("Friend"), ContactType.NEUTRAL.code()));
c.automute = parseFlag(fields.get("Automute"));
c.ignorePublicMessages = parseFlag(fields.get("IgnorePublicMessages"));
c.ignorePrivateMessages = parseFlag(fields.get("IgnorePrivateMessages"));
c.ignorePokes = parseFlag(fields.get("IgnorePokes"));
c.ignoreAvatar = parseFlag(fields.get("IgnoreAvatar"));
c.ignoreAwayMessage = parseFlag(fields.get("IgnoreAwayMessage"));
c.nicknameDisplay = NicknameDisplay.fromCode(parseInt(fields.get("NickShowType"), 0));
c.hasVolumeModifier = parseFlag(fields.get("HaveVolumeModifier"));
c.volumeModifier = parseDouble(fields.get("VolumeModifier"), 0);
c.whisperAllowed = !fields.containsKey("WhisperAllow") || parseFlag(fields.get("WhisperAllow"));
c.phoneticNickname = fields.getOrDefault("PhoneticNickname", "");
c.lastSeen = parseLastSeen(fields.get("LastSeen"));
c.lastSeenServerName = fields.getOrDefault("LastSeenServerName", "");
// TeamSpeak's own writer misspells the key in one of its code paths.
c.lastSeenServerAddress = fields.getOrDefault("LastSeenServerAddress",
fields.getOrDefault("LastSeenServerAddres", ""));
return c;
}
private static void line(StringBuilder sb, String key, String value) {
sb.append(key).append('=').append(value == null ? "" : value.replace("\n", " ")).append('\n');
}
private static String flag(boolean b) {
return b ? "1" : "0";
}
private static String formatVolume(double db) {
if (db == Math.rint(db)) return Integer.toString((int) db);
return String.format(Locale.ROOT, "%.2f", db);
}
private static boolean parseFlag(String v) {
if (v == null) return false;
String t = v.trim();
return t.equals("1") || t.equalsIgnoreCase("true");
}
private static int parseInt(String v, int def) {
if (v == null) return def;
try {
return Integer.parseInt(v.trim());
} catch (NumberFormatException e) {
return def;
}
}
private static double parseDouble(String v, double def) {
if (v == null) return def;
try {
return Double.parseDouble(v.trim());
} catch (NumberFormatException e) {
return def;
}
}
private static LocalDateTime parseLastSeen(String v) {
if (v == null || v.isBlank()) return null;
try {
return LocalDateTime.parse(v.trim(), LAST_SEEN);
} catch (DateTimeParseException e) {
return null;
}
}
}

View File

@@ -0,0 +1,99 @@
package com.ts3client.contacts;
import java.util.Properties;
/**
* What a newly added contact starts out with, kept separately for friends, blocked
* and neutral clients — TeamSpeak's "Contact Manager Default Settings".
*/
public final class ContactDefaults {
/** The defaults for one contact type. */
public static final class Entry {
public NicknameDisplay nicknameDisplay = NicknameDisplay.BOTH;
public boolean automute;
public boolean ignorePublicMessages;
public boolean ignorePrivateMessages;
public boolean ignorePokes;
public boolean ignoreAvatar;
public boolean ignoreAwayMessage;
public boolean ignoreWhisper;
public void copyFrom(Entry other) {
nicknameDisplay = other.nicknameDisplay;
automute = other.automute;
ignorePublicMessages = other.ignorePublicMessages;
ignorePrivateMessages = other.ignorePrivateMessages;
ignorePokes = other.ignorePokes;
ignoreAvatar = other.ignoreAvatar;
ignoreAwayMessage = other.ignoreAwayMessage;
ignoreWhisper = other.ignoreWhisper;
}
private void load(Properties p, String prefix) {
nicknameDisplay = parseDisplay(p.getProperty(prefix + "nicknameDisplay"), nicknameDisplay);
automute = parseB(p.getProperty(prefix + "automute"), automute);
ignorePublicMessages = parseB(p.getProperty(prefix + "ignorePublicMessages"), ignorePublicMessages);
ignorePrivateMessages = parseB(p.getProperty(prefix + "ignorePrivateMessages"), ignorePrivateMessages);
ignorePokes = parseB(p.getProperty(prefix + "ignorePokes"), ignorePokes);
ignoreAvatar = parseB(p.getProperty(prefix + "ignoreAvatar"), ignoreAvatar);
ignoreAwayMessage = parseB(p.getProperty(prefix + "ignoreAwayMessage"), ignoreAwayMessage);
ignoreWhisper = parseB(p.getProperty(prefix + "ignoreWhisper"), ignoreWhisper);
}
private void store(Properties p, String prefix) {
p.setProperty(prefix + "nicknameDisplay", nicknameDisplay.name());
p.setProperty(prefix + "automute", Boolean.toString(automute));
p.setProperty(prefix + "ignorePublicMessages", Boolean.toString(ignorePublicMessages));
p.setProperty(prefix + "ignorePrivateMessages", Boolean.toString(ignorePrivateMessages));
p.setProperty(prefix + "ignorePokes", Boolean.toString(ignorePokes));
p.setProperty(prefix + "ignoreAvatar", Boolean.toString(ignoreAvatar));
p.setProperty(prefix + "ignoreAwayMessage", Boolean.toString(ignoreAwayMessage));
p.setProperty(prefix + "ignoreWhisper", Boolean.toString(ignoreWhisper));
}
}
private static final String PREFIX = "contacts.defaults.";
private final Entry friend = new Entry();
private final Entry blocked = new Entry();
private final Entry neutral = new Entry();
public Entry of(ContactType type) {
switch (type) {
case FRIEND:
return friend;
case BLOCKED:
return blocked;
default:
return neutral;
}
}
public void copyFrom(ContactDefaults other) {
friend.copyFrom(other.friend);
blocked.copyFrom(other.blocked);
neutral.copyFrom(other.neutral);
}
public void load(Properties p) {
for (ContactType t : ContactType.values()) of(t).load(p, PREFIX + t.name().toLowerCase() + ".");
}
public void store(Properties p) {
for (ContactType t : ContactType.values()) of(t).store(p, PREFIX + t.name().toLowerCase() + ".");
}
private static boolean parseB(String v, boolean def) {
return v == null ? def : Boolean.parseBoolean(v.trim());
}
private static NicknameDisplay parseDisplay(String v, NicknameDisplay def) {
if (v == null) return def;
try {
return NicknameDisplay.valueOf(v.trim());
} catch (IllegalArgumentException e) {
return def;
}
}
}

View File

@@ -0,0 +1,235 @@
package com.ts3client.contacts;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* The contact list (TeamSpeak's "Friend/Foe manager"): who is a friend, who is
* blocked, and how each of them is displayed and filtered.
*
* <p>Persisted in {@code ~/.ts3jclient/contacts.txt} as TeamSpeak's own contact
* records separated by blank lines — the same text the official client keeps in
* the {@code Contacts} table of its {@code settings.db}, so entries can be moved
* between the two verbatim. On first run the list is seeded from an installed
* TeamSpeak client's database, when one is found.
*
* <p>Safe to use from any thread: lookups are lock-free on a snapshot, and
* mutations are serialised and announced to listeners on the calling thread.
*/
public final class ContactStore {
/** Told after the list changed in any way; called on whatever thread made the change. */
public interface Listener {
void onContactsChanged();
}
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
private static final File FILE = new File(DIR, "contacts.txt");
private final File file;
private final Map<String, Contact> byUid = new LinkedHashMap<>();
/** Immutable snapshot for readers, replaced under the lock after every change. */
private volatile List<Contact> snapshot = List.of();
private final List<Listener> listeners = new CopyOnWriteArrayList<>();
ContactStore(File file) {
this.file = file;
}
/** Loads the list, seeding it from the TeamSpeak 3 client's database the first time round. */
public static ContactStore load() {
ContactStore store = new ContactStore(FILE);
if (FILE.isFile()) {
store.readFile();
} else {
List<Contact> imported = TeamSpeakSettingsDb.readContacts();
if (!imported.isEmpty()) {
store.replaceAll(imported);
store.save();
}
}
return store;
}
void readFile() {
String text;
try {
text = Files.readString(file.toPath(), StandardCharsets.UTF_8);
} catch (IOException e) {
return;
}
List<Contact> parsed = new ArrayList<>();
for (String record : text.split("\\r?\\n\\s*\\r?\\n")) {
Contact c = Contact.parseRecord(record);
if (c != null) parsed.add(c);
}
synchronized (this) {
byUid.clear();
for (Contact c : parsed) byUid.put(c.uniqueId, c);
snapshot = List.copyOf(byUid.values());
}
}
public void save() {
List<Contact> all = snapshot;
StringBuilder sb = new StringBuilder();
for (Contact c : all) {
if (sb.length() > 0) sb.append('\n');
sb.append(c.toRecord());
}
try {
if (!DIR.isDirectory()) {
//noinspection ResultOfMethodCallIgnored
DIR.mkdirs();
}
Files.writeString(file.toPath(), sb.toString(), StandardCharsets.UTF_8);
} catch (IOException ignored) {
}
}
public void addListener(Listener l) {
listeners.add(l);
}
public void removeListener(Listener l) {
listeners.remove(l);
}
// ---- lookups ----
/** Every contact, in list order. */
public List<Contact> all() {
return snapshot;
}
public Contact find(String uniqueId) {
if (uniqueId == null || uniqueId.isEmpty()) return null;
for (Contact c : snapshot) {
if (c.uniqueId.equals(uniqueId)) return c;
}
return null;
}
/** The type a client is filed under, {@code null} when it is not a contact. */
public ContactType typeOf(String uniqueId) {
Contact c = find(uniqueId);
return c == null ? null : c.type;
}
/** The name a client should be shown under: its contact's custom name, else the nickname. */
public String displayName(String uniqueId, String nickname) {
Contact c = find(uniqueId);
return c == null ? nickname : c.displayName(nickname);
}
// ---- mutations ----
/**
* Files a client as a contact of the given type, applying the type's defaults to
* a new entry and only re-typing an existing one.
*
* @return the resulting contact
*/
public Contact add(String uniqueId, String nickname, ContactType type, ContactDefaults defaults) {
Contact contact;
synchronized (this) {
contact = byUid.get(uniqueId);
if (contact == null) {
contact = new Contact(uniqueId);
contact.nickname = nickname == null ? "" : nickname;
contact.applyDefaults(defaults.of(type));
byUid.put(uniqueId, contact);
}
contact.type = type;
snapshot = List.copyOf(byUid.values());
}
changed();
return contact;
}
/** Stores an edited contact, replacing any entry with the same unique id. */
public void put(Contact contact) {
synchronized (this) {
Contact existing = byUid.get(contact.uniqueId);
if (existing != null && existing != contact) existing.copyFrom(contact);
else byUid.put(contact.uniqueId, contact);
snapshot = List.copyOf(byUid.values());
}
changed();
}
public void remove(String uniqueId) {
synchronized (this) {
if (byUid.remove(uniqueId) == null) return;
snapshot = List.copyOf(byUid.values());
}
changed();
}
/** Takes over a whole list, e.g. one read from the TeamSpeak client. */
public void replaceAll(Collection<Contact> contacts) {
synchronized (this) {
byUid.clear();
for (Contact c : contacts) byUid.put(c.uniqueId, c);
snapshot = List.copyOf(byUid.values());
}
changed();
}
/**
* Merges contacts read from elsewhere: entries for clients not on the list yet are
* added, existing ones are overwritten.
*
* @return how many entries were new
*/
public int merge(Collection<Contact> contacts) {
int added = 0;
synchronized (this) {
for (Contact c : contacts) {
if (byUid.put(c.uniqueId, c) == null) added++;
}
snapshot = List.copyOf(byUid.values());
}
changed();
return added;
}
/**
* Notes that a contact is online right now. A sighting within the same minute on the
* same server changes nothing, so a busy server does not rewrite the file per event.
*/
public void markSeen(String uniqueId, String serverName, String serverAddress) {
Contact c = find(uniqueId);
if (c == null) return;
LocalDateTime now = LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS);
synchronized (this) {
if (c.lastSeen != null && ChronoUnit.MINUTES.between(c.lastSeen, now) < 1
&& c.lastSeenServerAddress.equals(orEmpty(serverAddress))) {
return;
}
c.lastSeen = now;
c.lastSeenServerName = orEmpty(serverName);
c.lastSeenServerAddress = orEmpty(serverAddress);
}
changed();
}
private void changed() {
save();
for (Listener l : listeners) l.onContactsChanged();
}
private static String orEmpty(String s) {
return s == null ? "" : s;
}
}

View File

@@ -0,0 +1,29 @@
package com.ts3client.contacts;
/** How a contact is classed; the codes are the ones TeamSpeak stores in {@code Friend=}. */
public enum ContactType {
FRIEND(0, "Friend"),
BLOCKED(1, "Blocked"),
NEUTRAL(2, "Neutral");
private final int code;
private final String label;
ContactType(int code, String label) {
this.code = code;
this.label = label;
}
public int code() {
return code;
}
public String label() {
return label;
}
public static ContactType fromCode(int code) {
for (ContactType t : values()) if (t.code == code) return t;
return NEUTRAL;
}
}

View File

@@ -0,0 +1,43 @@
package com.ts3client.contacts;
/** Which name a contact is shown under; the codes are TeamSpeak's {@code NickShowType=}. */
public enum NicknameDisplay {
/** "Custom name and nickname": the custom name with the live nickname in brackets. */
BOTH(0, "Custom name and nickname"),
ONLY_CUSTOM(1, "Only custom name"),
ONLY_NICKNAME(2, "Only nickname");
private final int code;
private final String label;
NicknameDisplay(int code, String label) {
this.code = code;
this.label = label;
}
public int code() {
return code;
}
public String label() {
return label;
}
public static NicknameDisplay fromCode(int code) {
for (NicknameDisplay d : values()) if (d.code == code) return d;
return BOTH;
}
/** Renders a nickname the way this mode asks for it; a blank custom name always yields the nickname. */
public String render(String customName, String nickname) {
if (customName == null || customName.isBlank()) return nickname;
switch (this) {
case ONLY_CUSTOM:
return customName;
case ONLY_NICKNAME:
return nickname;
default:
return customName.equals(nickname) ? nickname : customName + " (" + nickname + ")";
}
}
}

View File

@@ -0,0 +1,288 @@
package com.ts3client.contacts;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* A minimal read-only reader for SQLite 3 database files, enough to dump the rows
* of a table: it walks the table's b-tree and decodes each record, overflow pages
* included. It exists so the TeamSpeak client's {@code settings.db} can be read
* without a JDBC driver and its native library.
*
* <p>Only the main database file is read. A write-ahead log that has not been
* checkpointed yet is not consulted; TeamSpeak uses a rollback journal, whose
* committed state is in the main file.
*/
final class SqliteReader implements AutoCloseable {
private static final int HEADER_SIZE = 100;
private static final int INTERIOR_TABLE = 0x05;
private static final int LEAF_TABLE = 0x0d;
/** A table's column names and rows; values are {@link Long}, {@link Double}, {@link String}, {@code byte[]} or {@code null}. */
static final class Table {
final List<String> columns;
final List<Object[]> rows;
Table(List<String> columns, List<Object[]> rows) {
this.columns = columns;
this.rows = rows;
}
int columnIndex(String name) {
for (int i = 0; i < columns.size(); i++) {
if (columns.get(i).equalsIgnoreCase(name)) return i;
}
return -1;
}
}
private final RandomAccessFile file;
private final int pageSize;
private final int usableSize;
private final int encoding;
SqliteReader(java.io.File path) throws IOException {
file = new RandomAccessFile(path, "r");
byte[] header = new byte[HEADER_SIZE];
file.readFully(header);
if (!new String(header, 0, 15, StandardCharsets.US_ASCII).equals("SQLite format 3")) {
file.close();
throw new IOException("Not an SQLite 3 database");
}
int size = u16(header, 16);
pageSize = size == 1 ? 65536 : size;
usableSize = pageSize - (header[20] & 0xFF);
encoding = (int) u32(header, 56);
}
@Override
public void close() throws IOException {
file.close();
}
/** Reads a whole table, or returns {@code null} when the schema has no such table. */
Table readTable(String tableName) throws IOException {
Object[] schema = null;
for (Object[] row : rowsOf(1)) {
if (row.length < 5 || !"table".equals(row[0])) continue;
if (String.valueOf(row[1]).equalsIgnoreCase(tableName)) {
schema = row;
break;
}
}
if (schema == null || !(schema[3] instanceof Long)) return null;
List<String> columns = columnNames(String.valueOf(schema[4]));
return new Table(columns, rowsOf(((Long) schema[3]).intValue()));
}
/** The column names out of a {@code CREATE TABLE} statement, in declaration order. */
static List<String> columnNames(String sql) {
List<String> names = new ArrayList<>();
int open = sql.indexOf('(');
int close = sql.lastIndexOf(')');
if (open < 0 || close <= open) return names;
for (String definition : splitTopLevel(sql.substring(open + 1, close))) {
String[] tokens = definition.trim().split("\\s+");
if (tokens.length == 0 || tokens[0].isEmpty()) continue;
String first = tokens[0].toUpperCase(Locale.ROOT);
// Table constraints live in the same list as columns.
if (first.equals("PRIMARY") || first.equals("UNIQUE") || first.equals("CHECK")
|| first.equals("FOREIGN") || first.equals("CONSTRAINT")) {
continue;
}
names.add(unquote(tokens[0]));
}
return names;
}
/** Splits on commas outside parentheses, so {@code PRIMARY KEY (a, b)} stays one item. */
private static List<String> splitTopLevel(String s) {
List<String> parts = new ArrayList<>();
int depth = 0;
int start = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '(') depth++;
else if (c == ')') depth--;
else if (c == ',' && depth == 0) {
parts.add(s.substring(start, i));
start = i + 1;
}
}
parts.add(s.substring(start));
return parts;
}
private static String unquote(String s) {
if (s.length() >= 2) {
char c = s.charAt(0);
if ((c == '"' || c == '`' || c == '\'' || c == '[') && (s.endsWith(String.valueOf(c)) || c == '[' && s.endsWith("]"))) {
return s.substring(1, s.length() - 1);
}
}
return s;
}
// ---- b-tree walk ----
private List<Object[]> rowsOf(int rootPage) throws IOException {
List<Object[]> rows = new ArrayList<>();
walk(rootPage, rows, 0);
return rows;
}
private void walk(int pageNumber, List<Object[]> rows, int depth) throws IOException {
if (depth > 64) throw new IOException("B-tree too deep");
byte[] page = readPage(pageNumber);
int base = pageNumber == 1 ? HEADER_SIZE : 0;
int type = page[base] & 0xFF;
int cells = u16(page, base + 3);
int headerSize = type == INTERIOR_TABLE ? 12 : 8;
int pointers = base + headerSize;
if (type == INTERIOR_TABLE) {
for (int i = 0; i < cells; i++) {
int cell = u16(page, pointers + 2 * i);
walk((int) u32(page, cell), rows, depth + 1);
}
walk((int) u32(page, base + 8), rows, depth + 1);
} else if (type == LEAF_TABLE) {
for (int i = 0; i < cells; i++) {
int cell = u16(page, pointers + 2 * i);
rows.add(decodeRecord(leafPayload(page, cell)));
}
} else {
throw new IOException("Unexpected page type " + type);
}
}
/** The full payload of a table leaf cell, following the overflow chain when it spills. */
private byte[] leafPayload(byte[] page, int cell) throws IOException {
long[] varint = new long[2];
int pos = readVarint(page, cell, varint);
int payloadSize = (int) varint[0];
pos = readVarint(page, pos, varint); // rowid
int usable = usableSize;
int maxLocal = usable - 35;
int minLocal = (usable - 12) * 32 / 255 - 23;
int local;
if (payloadSize <= maxLocal) {
local = payloadSize;
} else {
int k = minLocal + (payloadSize - minLocal) % (usable - 4);
local = k <= maxLocal ? k : minLocal;
}
byte[] payload = new byte[payloadSize];
System.arraycopy(page, pos, payload, 0, local);
int filled = local;
long next = local < payloadSize ? u32(page, pos + local) : 0;
while (filled < payloadSize && next != 0) {
byte[] overflow = readPage((int) next);
int chunk = Math.min(payloadSize - filled, usable - 4);
System.arraycopy(overflow, 4, payload, filled, chunk);
filled += chunk;
next = u32(overflow, 0);
}
if (filled < payloadSize) throw new IOException("Truncated overflow chain");
return payload;
}
private Object[] decodeRecord(byte[] payload) throws IOException {
long[] varint = new long[2];
int pos = readVarint(payload, 0, varint);
int headerEnd = (int) varint[0];
List<Long> types = new ArrayList<>();
while (pos < headerEnd) {
pos = readVarint(payload, pos, varint);
types.add(varint[0]);
}
Object[] values = new Object[types.size()];
int data = headerEnd;
for (int i = 0; i < values.length; i++) {
long type = types.get(i);
if (type == 0) {
values[i] = null;
} else if (type >= 1 && type <= 6) {
int width = type == 5 ? 6 : type == 6 ? 8 : (int) type;
values[i] = readInt(payload, data, width);
data += width;
} else if (type == 7) {
values[i] = Double.longBitsToDouble(readInt(payload, data, 8));
data += 8;
} else if (type == 8 || type == 9) {
values[i] = type - 8;
} else if (type >= 12) {
int length = (int) ((type - (type % 2 == 0 ? 12 : 13)) / 2);
if (type % 2 == 0) {
byte[] blob = new byte[length];
System.arraycopy(payload, data, blob, 0, length);
values[i] = blob;
} else {
values[i] = decodeText(payload, data, length);
}
data += length;
} else {
throw new IOException("Reserved serial type " + type);
}
}
return values;
}
private String decodeText(byte[] b, int off, int len) {
switch (encoding) {
case 2:
return new String(b, off, len, StandardCharsets.UTF_16LE);
case 3:
return new String(b, off, len, StandardCharsets.UTF_16BE);
default:
return new String(b, off, len, StandardCharsets.UTF_8);
}
}
private byte[] readPage(int number) throws IOException {
if (number < 1) throw new IOException("Bad page number " + number);
byte[] page = new byte[pageSize];
file.seek((long) (number - 1) * pageSize);
file.readFully(page);
return page;
}
// ---- encoding primitives ----
private static int u16(byte[] b, int off) {
return (b[off] & 0xFF) << 8 | (b[off + 1] & 0xFF);
}
private static long u32(byte[] b, int off) {
return (long) (b[off] & 0xFF) << 24 | (b[off + 1] & 0xFF) << 16 | (b[off + 2] & 0xFF) << 8 | (b[off + 3] & 0xFF);
}
private static long readInt(byte[] b, int off, int width) {
long v = b[off] & 0xFF;
if ((v & 0x80) != 0) v |= -256L; // sign-extend
for (int i = 1; i < width; i++) v = v << 8 | (b[off + i] & 0xFF);
return v;
}
/** Reads a big-endian varint into {@code out[0]} and returns the position after it. */
private static int readVarint(byte[] b, int off, long[] out) {
long v = 0;
for (int i = 0; i < 8; i++) {
int c = b[off + i] & 0xFF;
v = v << 7 | (c & 0x7F);
if ((c & 0x80) == 0) {
out[0] = v;
return off + i + 1;
}
}
out[0] = v << 8 | (b[off + 8] & 0xFF);
return off + 9;
}
}

View File

@@ -0,0 +1,66 @@
package com.ts3client.contacts;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* Reads contacts straight out of an installed TeamSpeak 3 client's
* {@code settings.db}, whose {@code Contacts} table holds one record per contact
* in the format {@link Contact#parseRecord} understands.
*/
public final class TeamSpeakSettingsDb {
/** Overrides where the TeamSpeak client keeps its configuration. */
private static final String CONFIG_DIR_PROPERTY = "ts3.config.dir";
private TeamSpeakSettingsDb() {
}
/** The TeamSpeak client's settings database on this machine, or {@code null} when there is none. */
public static File locate() {
String configured = System.getProperty(CONFIG_DIR_PROPERTY);
List<File> candidates = new ArrayList<>();
if (configured != null && !configured.isBlank()) candidates.add(new File(configured.trim()));
String home = System.getProperty("user.home", ".");
candidates.add(new File(home, ".ts3client"));
String appData = System.getenv("APPDATA");
if (appData != null) candidates.add(new File(appData, "TS3Client"));
candidates.add(new File(home, "Library/Application Support/TeamSpeak 3"));
for (File dir : candidates) {
File db = new File(dir, "settings.db");
if (db.isFile()) return db;
}
return null;
}
/** The contacts of the local TeamSpeak client, or an empty list when it has none or cannot be read. */
public static List<Contact> readContacts() {
File db = locate();
if (db == null) return List.of();
try {
return readContacts(db);
} catch (Exception e) {
return List.of();
}
}
/** @throws java.io.IOException when the file is not a readable SQLite database */
public static List<Contact> readContacts(File db) throws java.io.IOException {
List<Contact> contacts = new ArrayList<>();
try (SqliteReader reader = new SqliteReader(db)) {
SqliteReader.Table table = reader.readTable("Contacts");
if (table == null) return contacts;
int value = table.columnIndex("value");
if (value < 0) return contacts;
for (Object[] row : table.rows) {
if (value >= row.length || !(row[value] instanceof String)) continue;
Contact c = Contact.parseRecord((String) row[value]);
if (c != null) contacts.add(c);
}
}
return contacts;
}
}

View File

@@ -1,6 +1,8 @@
package com.ts3client.net;
import com.github.manevolent.ts3j.event.*;
import com.github.manevolent.ts3j.api.TextMessageTargetMode;
import com.ts3client.contacts.Contact;
import com.ts3client.sound.SoundEvent;
import com.ts3client.sound.SoundNotifier;
@@ -48,6 +50,7 @@ final class ConnectionEventHandler implements TS3Listener {
c.channelGroupId = e.getClientChannelGroupId();
c.self = (e.getClientId() == conn.getSelfClientId());
if (e.getClientId() != conn.getSelfClientId()) {
conn.applyContact(c);
announceClientEntered(e);
logClientEntered(e);
}
@@ -570,6 +573,11 @@ final class ConnectionEventHandler implements TS3Listener {
@Override
public void onTextMessage(TextMessageEvent e) {
if (e.getInvokerId() == conn.getSelfClientId()) return; // don't echo our own
Contact contact = conn.contacts.find(e.getInvokerUniqueId());
if (contact != null) {
boolean isPrivate = e.getTargetMode() == TextMessageTargetMode.CLIENT;
if (isPrivate ? contact.ignorePrivateMessages : contact.ignorePublicMessages) return;
}
ConnectionListener.ChatScope scope;
SoundEvent notification;
switch (e.getTargetMode()) {
@@ -593,8 +601,11 @@ final class ConnectionEventHandler implements TS3Listener {
@Override
public void onClientPoke(ClientPokeEvent e) {
Contact contact = conn.contacts.find(e.getInvokerUniqueId());
if (contact != null && contact.ignorePokes) return;
conn.sound(SoundEvent.OTHER_RECEIVED_POKE, conn.clientVars(e.getInvokerId(), e.getInvokerName()));
conn.ui.onPoke(TeamspeakConnection.orEmpty(e.getInvokerName()), TeamspeakConnection.orEmpty(e.get("msg")));
String from = conn.contacts.displayName(e.getInvokerUniqueId(), TeamspeakConnection.orEmpty(e.getInvokerName()));
conn.ui.onPoke(from, TeamspeakConnection.orEmpty(e.get("msg")));
}
@Override

View File

@@ -306,6 +306,11 @@ public final class ServerModel {
clients.remove(id);
}
/** A snapshot of every client on the server. */
public synchronized List<ClientEntry> allClients() {
return new ArrayList<>(clients.values());
}
public synchronized ClientEntry findClientByUniqueId(String uniqueId) {
if (uniqueId == null || uniqueId.isEmpty()) return null;
for (ClientEntry c : clients.values()) {

View File

@@ -17,6 +17,8 @@ import com.ts3client.audio.VoiceOutput;
import com.ts3client.chatlog.ChatLogScope;
import com.ts3client.chatlog.ChatLogStore;
import com.ts3client.config.Settings;
import com.ts3client.contacts.Contact;
import com.ts3client.contacts.ContactStore;
import com.ts3client.net.filetransfer.FileTransfer;
import com.ts3client.net.filetransfer.FileTransferManager;
import com.ts3client.net.ban.BanEntry;
@@ -66,6 +68,8 @@ public final class TeamspeakConnection implements TS3Listener {
private final IconRepository icons;
/** Package-private: read directly by {@link ConnectionEventHandler}. */
final SoundNotifier sounds;
/** Friends, blocked clients and their per-client filters; also read by {@link ConnectionEventHandler}. */
final ContactStore contacts;
/** The {@link TS3Listener} side of this connection; see its class doc. */
private final ConnectionEventHandler events = new ConnectionEventHandler(this);
/** Builds {@code getconnectioninfo} snapshots; also package-private for {@link ConnectionEventHandler}. */
@@ -112,11 +116,12 @@ public final class TeamspeakConnection implements TS3Listener {
private volatile long lastMutedTalkNanos;
public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui,
SoundNotifier sounds) {
SoundNotifier sounds, ContactStore contacts) {
this.settings = settings;
this.audio = audio;
this.ui = ui;
this.sounds = sounds;
this.contacts = contacts;
this.icons = new IconRepository(this::fetchIcon, ui::onIconsUpdated);
}
@@ -266,7 +271,9 @@ public final class TeamspeakConnection implements TS3Listener {
client.addListener(this);
client.addListener(events);
client.setVoiceHandler(playback::handleVoice);
client.setWhisperHandler(playback::handleWhisper);
client.setWhisperHandler(whisper -> {
if (isWhisperAllowed(whisper.getClientId())) playback.handleWhisper(whisper);
});
client.setExceptionHandler(t -> {
// A late packet dispatched after close() rejects on the shut-down
// command executor; harmless during teardown.
@@ -998,6 +1005,76 @@ public final class TeamspeakConnection implements TS3Listener {
* Our own unique identifier on this server, which is how the server names us as a
* ban's creator; empty while not connected.
*/
// ---- contacts ----
public ContactStore getContacts() {
return contacts;
}
/** The name a client is shown under: its contact's custom name where it has one. */
public String displayName(ClientEntry client) {
return contacts.displayName(client.uniqueId, client.nickname);
}
/**
* Whether a whisper from a client is played, per the global whisper policy and the
* client's contact entry. A client that has already left is judged by the policy alone.
*/
boolean isWhisperAllowed(int clientId) {
Settings.WhisperPolicy policy = settings.whisperPolicy;
if (policy == Settings.WhisperPolicy.NOBODY) return false;
ClientEntry c = model.getClient(clientId);
Contact contact = c == null ? null : contacts.find(c.uniqueId);
if (contact != null) return contact.whisperAllowed;
return policy == Settings.WhisperPolicy.ALLOW_UNLESS_DENIED;
}
/**
* Applies a client's contact entry to its playback — the automatic mute and the
* volume modifier — and records the sighting. Called when the client appears and
* whenever its entry is edited.
*/
void applyContact(ClientEntry client) {
Contact contact = applyContactPlayback(client);
if (contact != null) contacts.markSeen(client.uniqueId, model.getServerName(), serverHost + ":" + serverPort);
}
private Contact applyContactPlayback(ClientEntry client) {
if (client == null || client.self) return null;
Contact contact = contacts.find(client.uniqueId);
if (contact != null && playback != null) {
if (contact.automute) playback.setClientMuted(client.id, true);
playback.setClientVolume(client.id, contact.volumeGain());
}
return contact;
}
/**
* Re-applies every contact entry to the clients on the server, after the list changed.
* A client that just became a contact is also marked as seen; the store only reports
* that as a change once, so the re-application it triggers stops there.
*/
public void applyContacts() {
if (!connected) return;
for (ClientEntry c : model.allClients()) applyContact(c);
}
/** Remembers a per-client volume, in the contact entry when the client has one. */
public void setClientVolumeModifier(ClientEntry client, double decibels) {
if (playback != null) playback.setClientVolume(client.id, Math.pow(10.0, decibels / 20.0));
Contact contact = contacts.find(client.uniqueId);
if (contact == null) return;
contact.hasVolumeModifier = decibels != 0;
contact.volumeModifier = decibels;
contacts.put(contact);
}
public double getClientVolumeModifier(ClientEntry client) {
if (playback == null) return 0;
double gain = playback.getClientVolume(client.id);
return gain <= 0 ? -60 : 20.0 * Math.log10(gain);
}
public String getSelfUniqueId() {
ClientEntry self = model.getClient(selfClientId);
if (self != null && !self.uniqueId.isEmpty()) return self.uniqueId;

View File

@@ -0,0 +1,99 @@
package com.ts3client.contacts;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.util.List;
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 ContactStoreTest {
@TempDir
File dir;
@Test
void addsWithTheTypeDefaultsAndPersistsInTeamSpeakRecords() throws Exception {
File file = new File(dir, "contacts.txt");
ContactStore store = new ContactStore(file);
ContactDefaults defaults = new ContactDefaults();
defaults.of(ContactType.BLOCKED).ignorePokes = true;
defaults.of(ContactType.BLOCKED).ignoreWhisper = true;
defaults.of(ContactType.BLOCKED).nicknameDisplay = NicknameDisplay.ONLY_NICKNAME;
AtomicInteger changes = new AtomicInteger();
store.addListener(changes::incrementAndGet);
Contact bob = store.add("bob=", "Bob", ContactType.BLOCKED, defaults);
assertTrue(bob.ignorePokes);
assertTrue(!bob.whisperAllowed);
assertEquals(NicknameDisplay.ONLY_NICKNAME, bob.nicknameDisplay);
assertEquals("Bob2", store.displayName("bob=", "Bob2"));
bob.nicknameDisplay = NicknameDisplay.BOTH;
assertEquals("Bob (Bob2)", store.displayName("bob=", "Bob2"));
assertEquals(1, changes.get());
// Adding again only re-types; the edited flags stay.
bob.ignorePrivateMessages = true;
store.put(bob);
Contact again = store.add("bob=", "Other", ContactType.FRIEND, defaults);
assertTrue(again.ignorePrivateMessages);
assertEquals(ContactType.FRIEND, again.type);
assertEquals(ContactType.FRIEND, store.typeOf("bob="));
assertNull(store.typeOf("nobody"));
String text = Files.readString(file.toPath(), StandardCharsets.UTF_8);
assertTrue(text.startsWith("IDS=bob=\nNickname=Bob\nFriend=0\n"), text);
ContactStore reloaded = new ContactStore(file);
reloaded.readFile();
Contact back = reloaded.find("bob=");
assertNotNull(back);
assertTrue(back.ignorePrivateMessages);
assertEquals(ContactType.FRIEND, back.type);
}
@Test
void markSeenRecordsOnceAMinute() {
ContactStore store = new ContactStore(new File(dir, "c.txt"));
store.add("a=", "A", ContactType.NEUTRAL, new ContactDefaults());
AtomicInteger changes = new AtomicInteger();
store.addListener(changes::incrementAndGet);
store.markSeen("a=", "Server", "host:9987");
store.markSeen("a=", "Server", "host:9987");
store.markSeen("missing", "Server", "host:9987");
assertEquals(1, changes.get());
Contact a = store.find("a=");
assertEquals("Server", a.lastSeenServerName);
assertTrue(a.lastSeen.isAfter(LocalDateTime.now().minusMinutes(1)));
store.markSeen("a=", "Other", "other:9987");
assertEquals(2, changes.get());
assertEquals("other:9987", a.lastSeenServerAddress);
}
@Test
void mergeAddsNewAndOverwritesExisting() {
ContactStore store = new ContactStore(new File(dir, "m.txt"));
store.add("a=", "A", ContactType.NEUTRAL, new ContactDefaults());
Contact importedA = new Contact("a=");
importedA.nickname = "A imported";
importedA.type = ContactType.FRIEND;
Contact importedB = new Contact("b=");
importedB.nickname = "B";
assertEquals(1, store.merge(List.of(importedA, importedB)));
assertEquals(2, store.all().size());
assertEquals("A imported", store.find("a=").nickname);
assertEquals(ContactType.FRIEND, store.find("a=").type);
store.remove("a=");
assertEquals(1, store.all().size());
}
}

View File

@@ -0,0 +1,83 @@
package com.ts3client.contacts;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
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 ContactTest {
@Test
void recordRoundTripsInTeamSpeakFormat() {
Contact c = new Contact("AbCdEfGh1234567890abcdefghijk=");
c.nickname = "Alice";
c.type = ContactType.FRIEND;
c.automute = true;
c.ignorePokes = true;
c.nicknameDisplay = NicknameDisplay.ONLY_CUSTOM;
c.hasVolumeModifier = true;
c.volumeModifier = -6;
c.whisperAllowed = false;
c.phoneticNickname = "Ah-lees";
c.lastSeen = LocalDateTime.of(2024, 3, 1, 18, 33, 34);
c.lastSeenServerName = "Test Server";
c.lastSeenServerAddress = "server.lixko.eu:9987";
String record = c.toRecord();
assertTrue(record.startsWith("IDS=AbCdEfGh1234567890abcdefghijk=\nNickname=Alice\nFriend=0\nAutomute=1\n"));
assertTrue(record.contains("\nVolumeModifier=-6\n"));
assertTrue(record.contains("\nWhisperAllow=0\n"));
assertTrue(record.contains("\nLastSeen=2024-03-01T18:33:34\n"));
Contact parsed = Contact.parseRecord(record);
assertEquals(c.uniqueId, parsed.uniqueId);
assertEquals("Alice", parsed.nickname);
assertEquals(ContactType.FRIEND, parsed.type);
assertTrue(parsed.automute);
assertTrue(parsed.ignorePokes);
assertFalse(parsed.ignorePublicMessages);
assertEquals(NicknameDisplay.ONLY_CUSTOM, parsed.nicknameDisplay);
assertTrue(parsed.hasVolumeModifier);
assertEquals(-6.0, parsed.volumeModifier);
assertFalse(parsed.whisperAllowed);
assertEquals("Ah-lees", parsed.phoneticNickname);
assertEquals(c.lastSeen, parsed.lastSeen);
assertEquals("server.lixko.eu:9987", parsed.lastSeenServerAddress);
assertEquals(record, parsed.toRecord());
}
@Test
void missingFieldsFallBackToTeamSpeakDefaults() {
Contact c = Contact.parseRecord("IDS=abc=\nNickname=X\nFriend=1\n");
assertEquals(ContactType.BLOCKED, c.type);
assertTrue(c.whisperAllowed);
assertEquals(NicknameDisplay.BOTH, c.nicknameDisplay);
assertNull(c.lastSeen);
assertNull(Contact.parseRecord("Nickname=nobody\n"));
}
@Test
void displayNameFollowsTheShowType() {
Contact c = new Contact("u");
c.nickname = "Custom";
assertEquals("Custom (Live)", c.displayName("Live"));
assertEquals("Live", c.displayName("Live") == null ? null : NicknameDisplay.ONLY_NICKNAME.render("Custom", "Live"));
c.nicknameDisplay = NicknameDisplay.ONLY_CUSTOM;
assertEquals("Custom", c.displayName("Live"));
c.nickname = "";
assertEquals("Live", c.displayName("Live"));
}
@Test
void volumeModifierIsDecibels() {
Contact c = new Contact("u");
assertEquals(1.0, c.volumeGain());
c.hasVolumeModifier = true;
c.volumeModifier = -6;
assertEquals(0.501, c.volumeGain(), 0.001);
}
}

View File

@@ -0,0 +1,61 @@
package com.ts3client.contacts;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TeamSpeakSettingsDbTest {
private static File fixture() {
return new File(TeamSpeakSettingsDbTest.class.getResource("/contacts/settings.db").getFile());
}
@Test
void readsEveryContactOutOfTheDatabase() throws Exception {
List<Contact> contacts = TeamSpeakSettingsDb.readContacts(fixture());
assertEquals(60, contacts.size());
Contact alice = byUid(contacts, "AbCdEfGh1234567890abcdefghijk=");
assertEquals("Alice", alice.nickname);
assertEquals(ContactType.FRIEND, alice.type);
assertTrue(alice.hasVolumeModifier);
assertEquals(-6.0, alice.volumeModifier);
assertEquals("Test Server", alice.lastSeenServerName);
assertEquals(2024, alice.lastSeen.getYear());
Contact bob = byUid(contacts, "Zyxwvutsrqponmlkjihgfedcba00=");
assertEquals(ContactType.BLOCKED, bob.type);
assertTrue(bob.automute);
assertTrue(bob.ignorePokes);
assertTrue(!bob.whisperAllowed);
assertEquals(NicknameDisplay.ONLY_CUSTOM, bob.nicknameDisplay);
}
/** The third record spills onto overflow pages at the fixture's 512-byte page size. */
@Test
void followsOverflowPagesAndKeepsUnicode() throws Exception {
Contact carol = byUid(TeamSpeakSettingsDb.readContacts(fixture()), "Neutral0000000000000000000000=");
assertEquals("Carol Ünïcødé", carol.nickname);
assertEquals("Karol", carol.phoneticNickname);
assertEquals("X".repeat(900), carol.lastSeenServerName);
}
@Test
void parsesColumnNamesOutOfCreateTable() {
assertEquals(List.of("timestamp", "key", "value"), SqliteReader.columnNames(
"CREATE TABLE Contacts (timestamp INTEGER UNSIGNED NOT NULL, key VARCHAR NOT NULL UNIQUE, value VARCHAR)"));
assertEquals(List.of("a", "b"), SqliteReader.columnNames(
"CREATE TABLE \"t\"(\"a\" TEXT, [b] INT, PRIMARY KEY (a, b))"));
}
private static Contact byUid(List<Contact> contacts, String uid) {
for (Contact c : contacts) if (c.uniqueId.equals(uid)) return c;
assertNotNull(null, "no contact " + uid);
return null;
}
}