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:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
BIN
ts3-client/core/src/test/resources/contacts/settings.db
Normal file
BIN
ts3-client/core/src/test/resources/contacts/settings.db
Normal file
Binary file not shown.
Reference in New Issue
Block a user