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

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