Import bookmarks and identities from the TeamSpeak 3 client

The official client keeps what a myTeamSpeak account synchronises as protobuf
Item_Data blobs in the ProtobufItems table of settings.db. Decode them, adopt
them on first run when we have no identity yet, and offer a re-import from
Manage Bookmarks and Identities. The settings.db reader moves from contacts to
a new teamspeak package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 19:02:52 +00:00
parent bab89f9b50
commit dbb425403a
20 changed files with 805 additions and 76 deletions

View File

@@ -3,6 +3,7 @@ package com.ts3client.config;
import com.github.manevolent.ts3j.identity.LocalIdentity;
import org.ini4j.Ini;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
@@ -90,6 +91,15 @@ public final class IdentityStore {
return null;
}
/** The identity with this TeamSpeak unique ID, or {@code null}. */
public IdentityEntry byUniqueId(String uniqueId) {
if (uniqueId == null || uniqueId.isBlank()) return null;
for (IdentityEntry e : entries) {
if (e.getUniqueId().equals(uniqueId)) return e;
}
return null;
}
/**
* Resolves the identity to connect with: the requested one, else the configured
* default, else the first available — generating one when the store is empty.
@@ -126,6 +136,20 @@ public final class IdentityStore {
return create((name == null || name.isBlank()) ? "Imported identity" : name, identity);
}
/**
* Parses an identity in the {@code <offset>V<obfuscated key>} form TeamSpeak keeps
* in its INI files and its settings database.
*/
public static LocalIdentity parseIdentityString(String identity) throws IOException {
String ini = "[Identity]\r\nidentity=\"" + identity + "\"\r\n";
return LocalIdentity.read(new ByteArrayInputStream(ini.getBytes(StandardCharsets.US_ASCII)));
}
/** Files an existing key pair as a managed identity under the given name. */
public IdentityEntry add(String name, LocalIdentity identity) throws IOException {
return create(name, identity);
}
/** Writes an identity out in TeamSpeak's own format, ready to import elsewhere. */
public void exportTo(IdentityEntry entry, File target) throws IOException {
write(entry.getIdentity(), entry.getName(), target);

View File

@@ -1,5 +1,7 @@
package com.ts3client.contacts;
import com.ts3client.teamspeak.TeamSpeakSettingsDb;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

View File

@@ -1,66 +0,0 @@
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

@@ -0,0 +1,107 @@
package com.ts3client.teamspeak;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* Minimal protocol-buffers wire-format cursor: walks a message field by field
* without needing a compiled schema. Callers switch on {@link #fieldNumber()}
* and pull the value with the accessor matching the field's declared type;
* anything else is skipped with {@link #skip()}.
*/
final class ProtobufReader {
private static final int VARINT = 0;
private static final int FIXED64 = 1;
private static final int LENGTH_DELIMITED = 2;
private static final int FIXED32 = 5;
private final byte[] data;
private final int end;
private int pos;
private int fieldNumber;
private int wireType;
ProtobufReader(byte[] data) {
this(data, 0, data.length);
}
private ProtobufReader(byte[] data, int start, int end) {
this.data = data;
this.pos = start;
this.end = end;
}
/** Advances to the next field; {@code false} at the end of the message. */
boolean next() throws IOException {
if (pos >= end) return false;
long tag = readVarint();
fieldNumber = (int) (tag >>> 3);
wireType = (int) (tag & 7);
if (fieldNumber == 0) throw new IOException("Invalid protobuf tag");
return true;
}
int fieldNumber() {
return fieldNumber;
}
long readVarint() throws IOException {
long result = 0;
for (int shift = 0; shift < 64; shift += 7) {
if (pos >= end) throw new IOException("Truncated protobuf varint");
int b = data[pos++];
result |= (long) (b & 0x7F) << shift;
if ((b & 0x80) == 0) return result;
}
throw new IOException("Malformed protobuf varint");
}
boolean readBool() throws IOException {
return readVarint() != 0;
}
String readString() throws IOException {
int length = readLength();
String s = new String(data, pos, length, StandardCharsets.UTF_8);
pos += length;
return s;
}
byte[] readBytes() throws IOException {
int length = readLength();
byte[] out = new byte[length];
System.arraycopy(data, pos, out, 0, length);
pos += length;
return out;
}
/** A reader positioned over an embedded message; this reader moves past it. */
ProtobufReader readMessage() throws IOException {
int length = readLength();
ProtobufReader nested = new ProtobufReader(data, pos, pos + length);
pos += length;
return nested;
}
void skip() throws IOException {
switch (wireType) {
case VARINT -> readVarint();
case FIXED64 -> pos += 8;
case LENGTH_DELIMITED -> {
int length = readLength();
pos += length;
}
case FIXED32 -> pos += 4;
default -> throw new IOException("Unsupported protobuf wire type " + wireType);
}
if (pos > end) throw new IOException("Truncated protobuf message");
}
private int readLength() throws IOException {
if (wireType != LENGTH_DELIMITED) throw new IOException("Field " + fieldNumber + " is not length-delimited");
long length = readVarint();
if (length < 0 || pos + length > end) throw new IOException("Truncated protobuf message");
return (int) length;
}
}

View File

@@ -1,4 +1,4 @@
package com.ts3client.contacts;
package com.ts3client.teamspeak;
import java.io.IOException;
import java.io.RandomAccessFile;

View File

@@ -0,0 +1,38 @@
package com.ts3client.teamspeak;
/**
* One entry of the TeamSpeak 3 client's synchronised item store — the data that
* a myTeamSpeak account syncs between installations. Since client 3.1 the
* bookmarks and identities of every installation live there, signed in or not.
* Whisper lists, hotkey profiles and the other item classes are not modelled.
*/
public sealed interface SyncItem {
/** The item's own id, a UUID string. */
String uuid();
/** UUID of the {@link Folder} the item sits in, or empty at the top level. */
String parentUuid();
record Folder(String uuid, String parentUuid, String name) implements SyncItem {
}
/**
* @param identity the key in TeamSpeak's obfuscated {@code <offset>V<data>} identity form
* @param nickname the nickname used with this identity when a bookmark names none
*/
record Identity(String uuid, String parentUuid, String identity, String name,
String nickname, String phoneticNickname, boolean isDefault) implements SyncItem {
}
/**
* @param identityUuid {@link Identity#uuid()} of the identity to connect with; empty for the default
* @param defaultChannel channel path to join, "/"-separated; empty for the server default
* @param defaultChannelId the same channel by id, {@code 0} when unknown
*/
record Bookmark(String uuid, String parentUuid, String name, String address, int port,
String nickname, String phoneticNickname, String identityUuid,
String serverPassword, String defaultChannel, String defaultChannelPassword,
long defaultChannelId, boolean autoconnect) implements SyncItem {
}
}

View File

@@ -0,0 +1,102 @@
package com.ts3client.teamspeak;
import java.io.IOException;
/**
* Decodes a serialised {@code com.teamspeak.sync.proto.Item_Data} message (the
* schema is embedded in the TeamSpeak client binary) into a {@link SyncItem}.
*/
final class SyncItemDecoder {
/** Item_Data.parent of a top-level item. */
private static final String ROOT_PARENT = "ffffffff-ffff-ffff-ffff-ffffffffffff";
private static final int ITEM_DELETED = 2;
private SyncItemDecoder() {
}
/**
* @return the item, or {@code null} when it is of an unmodelled class or was
* deleted locally and only lingers until the deletion is synced
*/
static SyncItem decode(byte[] blob) throws IOException {
String uuid = "";
String parent = "";
boolean deleted = false;
ProtobufReader content = null;
int contentField = 0;
ProtobufReader r = new ProtobufReader(blob);
while (r.next()) {
switch (r.fieldNumber()) {
case 2 -> uuid = r.readString();
case 4 -> deleted = r.readVarint() == ITEM_DELETED;
case 7 -> parent = r.readString();
case 16, 17, 22 -> {
contentField = r.fieldNumber();
content = r.readMessage();
}
default -> r.skip();
}
}
if (deleted || content == null) return null;
if (parent.equals(ROOT_PARENT)) parent = "";
return switch (contentField) {
case 16 -> bookmark(uuid, parent, content);
case 17 -> identity(uuid, parent, content);
default -> folder(uuid, parent, content);
};
}
private static SyncItem.Bookmark bookmark(String uuid, String parent, ProtobufReader r) throws IOException {
String name = "", address = "", nickname = "", phonetic = "", identity = "";
String serverPassword = "", channel = "", channelPassword = "";
int port = 9987;
long channelId = 0;
boolean autoconnect = false;
while (r.next()) {
switch (r.fieldNumber()) {
case 1 -> name = r.readString();
case 2 -> address = r.readString();
case 3 -> port = (int) r.readVarint();
case 4 -> nickname = r.readString();
case 5 -> phonetic = r.readString();
case 6 -> identity = r.readString();
case 7 -> serverPassword = r.readString();
case 8 -> channel = r.readString();
case 9 -> channelPassword = r.readString();
case 10 -> channelId = r.readVarint();
case 14 -> autoconnect = r.readBool();
default -> r.skip();
}
}
return new SyncItem.Bookmark(uuid, parent, name, address, port, nickname, phonetic, identity,
serverPassword, channel, channelPassword, channelId, autoconnect);
}
private static SyncItem.Identity identity(String uuid, String parent, ProtobufReader r) throws IOException {
String identity = "", name = "", nickname = "", phonetic = "";
boolean isDefault = false;
while (r.next()) {
switch (r.fieldNumber()) {
case 1 -> identity = r.readString();
case 2 -> name = r.readString();
case 3 -> nickname = r.readString();
case 4 -> phonetic = r.readString();
case 5 -> isDefault = r.readBool();
default -> r.skip();
}
}
return new SyncItem.Identity(uuid, parent, identity, name, nickname, phonetic, isDefault);
}
private static SyncItem.Folder folder(String uuid, String parent, ProtobufReader r) throws IOException {
String name = "";
while (r.next()) {
if (r.fieldNumber() == 1) name = r.readString();
else r.skip();
}
return new SyncItem.Folder(uuid, parent, name);
}
}

View File

@@ -0,0 +1,175 @@
package com.ts3client.teamspeak;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityEntry;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings;
import com.github.manevolent.ts3j.identity.LocalIdentity;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Carries the TeamSpeak 3 client's synchronised bookmarks and identities over
* into this client's stores. Importing is additive and idempotent: an identity
* already present (same unique ID) or a bookmark already present (same label,
* address and port) is left alone, and bookmarks are wired to the identities
* they referenced in TeamSpeak.
*/
public final class TeamSpeakImporter {
/** What an import did; {@code known} counts the items that were already there. */
public record Result(int identitiesAdded, int identitiesKnown, int bookmarksAdded, int bookmarksKnown) {
public int identities() {
return identitiesAdded + identitiesKnown;
}
public int bookmarks() {
return bookmarksAdded + bookmarksKnown;
}
}
private final IdentityStore identities;
private final Bookmarks bookmarks;
private final Settings settings;
public TeamSpeakImporter(IdentityStore identities, Bookmarks bookmarks, Settings settings) {
this.identities = identities;
this.bookmarks = bookmarks;
this.settings = settings;
}
/** Imports the identities only. */
public Result importIdentities(File db) throws IOException {
return importItems(TeamSpeakSettingsDb.readSyncItems(db), false, false);
}
/** Imports the bookmarks, together with the identities they connect with. */
public Result importBookmarks(File db) throws IOException {
return importItems(TeamSpeakSettingsDb.readSyncItems(db), true, true);
}
/** Imports every identity and bookmark. */
public Result importAll(File db) throws IOException {
return importItems(TeamSpeakSettingsDb.readSyncItems(db), true, false);
}
/**
* First run — no identity of our own yet — so adopt whatever the TeamSpeak 3
* client on this machine has, as the contact list does. Quiet on any failure.
*
* @return what was imported, or {@code null} when this was not a first run or there was nothing to import
*/
public Result seedIfFresh() {
if (!identities.isEmpty()) return null;
File db = TeamSpeakSettingsDb.locate();
if (db == null) return null;
try {
return importAll(db);
} catch (Exception e) {
return null;
}
}
private Result importItems(List<SyncItem> items, boolean withBookmarks, boolean onlyUsedIdentities)
throws IOException {
Map<String, SyncItem.Identity> syncIdentities = new LinkedHashMap<>();
List<SyncItem.Bookmark> syncBookmarks = new ArrayList<>();
for (SyncItem item : items) {
if (item instanceof SyncItem.Identity identity) syncIdentities.put(identity.uuid(), identity);
if (item instanceof SyncItem.Bookmark bookmark && !bookmark.address().isBlank()) syncBookmarks.add(bookmark);
}
if (onlyUsedIdentities) {
// Only the identities some bookmark connects with come along.
Set<String> referenced = new HashSet<>();
for (SyncItem.Bookmark bookmark : syncBookmarks) referenced.add(bookmark.identityUuid());
boolean defaultUsed = referenced.contains("");
syncIdentities.values().removeIf(identity ->
!referenced.contains(identity.uuid()) && !(defaultUsed && identity.isDefault()));
}
int identitiesAdded = 0, identitiesKnown = 0, bookmarksAdded = 0, bookmarksKnown = 0;
Map<String, IdentityEntry> imported = new HashMap<>();
SyncItem.Identity syncDefault = null;
boolean settingsChanged = false;
for (SyncItem.Identity identity : syncIdentities.values()) {
LocalIdentity key;
try {
key = IdentityStore.parseIdentityString(identity.identity());
} catch (IOException malformed) {
continue;
}
IdentityEntry entry = identities.byUniqueId(key.getUid().toBase64());
if (entry != null) {
identitiesKnown++;
} else {
entry = identities.add(nameOf(identity), key);
identitiesAdded++;
}
imported.put(identity.uuid(), entry);
if (!identity.isDefault()) continue;
syncDefault = identity;
// No default of our own yet: TeamSpeak's default identity and nickname become ours.
if (identities.byId(settings.defaultIdentityId) == null) {
settings.defaultIdentityId = entry.getId();
if (!identity.nickname().isBlank()) settings.nickname = identity.nickname();
settingsChanged = true;
}
}
if (settingsChanged) settings.save();
if (!withBookmarks) return new Result(identitiesAdded, identitiesKnown, 0, 0);
for (SyncItem.Bookmark bookmark : syncBookmarks) {
if (find(bookmark) != null) {
bookmarksKnown++;
continue;
}
// A bookmark naming no identity connects with the default one, as it does here.
SyncItem.Identity syncIdentity = bookmark.identityUuid().isEmpty()
? syncDefault : syncIdentities.get(bookmark.identityUuid());
bookmarks.add(toBookmark(bookmark, syncIdentity, imported.get(bookmark.identityUuid())));
bookmarksAdded++;
}
if (bookmarksAdded > 0) bookmarks.save();
return new Result(identitiesAdded, identitiesKnown, bookmarksAdded, bookmarksKnown);
}
private Bookmark toBookmark(SyncItem.Bookmark sync, SyncItem.Identity syncIdentity, IdentityEntry identity) {
String nickname = sync.nickname();
if (nickname.isBlank() && syncIdentity != null) nickname = syncIdentity.nickname();
if (nickname.isBlank()) nickname = settings.nickname;
Bookmark b = new Bookmark(sync.name(), sync.address(), sync.port(), nickname, sync.serverPassword());
b.identityId = identity == null ? "" : identity.getId();
b.channel = sync.defaultChannel();
if (b.channel.isBlank() && sync.defaultChannelId() != 0) b.channel = "/" + sync.defaultChannelId();
b.channelPassword = sync.defaultChannelPassword();
b.connectOnStartup = sync.autoconnect();
return b;
}
private Bookmark find(SyncItem.Bookmark sync) {
for (Bookmark b : bookmarks.all()) {
if (b.port == sync.port()
&& sync.address().equalsIgnoreCase(b.address == null ? "" : b.address)
&& sync.name().equals(b.label == null ? "" : b.label)) {
return b;
}
}
return null;
}
private static String nameOf(SyncItem.Identity identity) {
if (!identity.name().isBlank()) return identity.name();
return identity.nickname().isBlank() ? "TeamSpeak 3 identity" : identity.nickname();
}
}

View File

@@ -0,0 +1,100 @@
package com.ts3client.teamspeak;
import com.ts3client.contacts.Contact;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* Reads an installed TeamSpeak 3 client's {@code settings.db}: the {@code Contacts}
* table holds one record per contact in the format {@link Contact#parseRecord}
* understands, and {@code ProtobufItems} holds the myTeamSpeak-synchronised
* store — bookmarks, identities and their folders — as {@link SyncItem}s.
*/
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 IOException when the file is not a readable SQLite database */
public static List<Contact> readContacts(File db) throws IOException {
List<Contact> contacts = new ArrayList<>();
for (Object value : columnValues(db, "Contacts")) {
if (!(value instanceof String record)) continue;
Contact c = Contact.parseRecord(record);
if (c != null) contacts.add(c);
}
return contacts;
}
/**
* The synchronised items in database order. Rows that are not items — the
* table's checksum, unmodelled classes, pending deletions — are left out.
*
* @throws IOException when the file is not a readable SQLite database
*/
public static List<SyncItem> readSyncItems(File db) throws IOException {
List<SyncItem> items = new ArrayList<>();
for (Object value : columnValues(db, "ProtobufItems")) {
if (!(value instanceof byte[] blob)) continue;
SyncItem item;
try {
item = SyncItemDecoder.decode(blob);
} catch (IOException malformed) {
continue;
}
if (item != null) items.add(item);
}
return items;
}
/** The {@code value} column of a key/value table, in row order; empty when the table is absent. */
private static List<Object> columnValues(File db, String tableName) throws IOException {
List<Object> values = new ArrayList<>();
try (SqliteReader reader = new SqliteReader(db)) {
SqliteReader.Table table = reader.readTable(tableName);
if (table == null) return values;
int column = table.columnIndex("value");
if (column < 0) return values;
for (Object[] row : table.rows) {
if (column < row.length) values.add(row[column]);
}
}
return values;
}
}

View File

@@ -0,0 +1,82 @@
package com.ts3client.teamspeak;
import com.ts3client.config.IdentityStore;
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The fixture is the {@code ProtobufItems} table a fresh official client wrote
* (config, hotkey profile, "Default" identity, "TeamSpeak Public" bookmark,
* checksum), plus a folder, two more bookmarks, a deleted bookmark and a
* whisper list appended in the same encoding.
*/
class SyncItemsTest {
private static final String IDENTITY_UUID = "965483cf-210f-dd22-ee1f-33591b0587a4";
private static final String FOLDER_UUID = "0f0f0f0f-0000-4000-8000-000000000001";
static File fixture() {
return new File(SyncItemsTest.class.getResource("/teamspeak/sync.db").getFile());
}
@Test
void readsBookmarksIdentitiesAndFoldersOnly() throws Exception {
List<SyncItem> items = TeamSpeakSettingsDb.readSyncItems(fixture());
assertEquals(5, items.size());
assertEquals(List.of(IDENTITY_UUID, "ea120ce1-b68b-b314-46ee-e16b3bf10342", FOLDER_UUID,
"bbbbbbbb-0000-4000-8000-000000000002", "bbbbbbbb-0000-4000-8000-000000000003"),
items.stream().map(SyncItem::uuid).toList());
}
@Test
void decodesTheClientsOwnIdentityAndBookmark() throws Exception {
List<SyncItem> items = TeamSpeakSettingsDb.readSyncItems(fixture());
SyncItem.Identity identity = (SyncItem.Identity) items.get(0);
assertEquals("Default", identity.name());
assertEquals("SyncTester", identity.nickname());
assertEquals("", identity.parentUuid());
assertTrue(identity.isDefault());
assertTrue(identity.identity().matches("\\d+V[\\w+/=]+"));
assertNotNull(IdentityStore.parseIdentityString(identity.identity()).getUid());
SyncItem.Bookmark bookmark = (SyncItem.Bookmark) items.get(1);
assertEquals("TeamSpeak Public", bookmark.name());
assertEquals("voice.teamspeak.com", bookmark.address());
assertEquals(9987, bookmark.port());
assertEquals("", bookmark.identityUuid(), "connects with the default identity");
assertEquals("", bookmark.nickname());
assertFalse(bookmark.autoconnect());
}
@Test
void decodesFoldersAndEveryBookmarkField() throws Exception {
List<SyncItem> items = TeamSpeakSettingsDb.readSyncItems(fixture());
SyncItem.Folder folder = (SyncItem.Folder) items.get(2);
assertEquals("Clan servers", folder.name());
SyncItem.Bookmark inFolder = (SyncItem.Bookmark) items.get(3);
assertEquals(FOLDER_UUID, inFolder.parentUuid());
assertEquals("Lixko", inFolder.name());
assertEquals(IDENTITY_UUID, inFolder.identityUuid());
assertEquals("server.lixko.eu", inFolder.address());
assertEquals("Bookmark Nick", inFolder.nickname());
assertEquals("hunter2", inFolder.serverPassword());
assertEquals("Lobby/Games", inFolder.defaultChannel());
assertEquals("chanpw", inFolder.defaultChannelPassword());
assertTrue(inFolder.autoconnect());
SyncItem.Bookmark byId = (SyncItem.Bookmark) items.get(4);
assertEquals(9988, byId.port());
assertEquals(42, byId.defaultChannelId());
assertEquals("", byId.identityUuid());
}
}

View File

@@ -0,0 +1,85 @@
package com.ts3client.teamspeak;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityEntry;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.nio.file.Files;
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 TeamSpeakImporterTest {
/** The stores resolve {@code ~/.ts3jclient} once, so point them at scratch space before they load. */
@BeforeAll
static void isolateHome() throws Exception {
System.setProperty("user.home", Files.createTempDirectory("ts3j-import").toString());
}
@Test
void importsBookmarksWithTheIdentitiesTheyUse() throws Exception {
Settings settings = new Settings();
settings.nickname = "Fallback";
IdentityStore identities = IdentityStore.load(settings);
Bookmarks bookmarks = new Bookmarks();
bookmarks.add(new Bookmark("TeamSpeak Public", "VOICE.teamspeak.com", 9987, "me", ""));
TeamSpeakImporter importer = new TeamSpeakImporter(identities, bookmarks, settings);
TeamSpeakImporter.Result result = importer.importBookmarks(SyncItemsTest.fixture());
assertEquals(1, result.identitiesAdded());
assertEquals(2, result.bookmarksAdded());
assertEquals(1, result.bookmarksKnown());
IdentityEntry imported = identities.all().get(0);
assertEquals("Default", imported.getName());
assertEquals(imported.getId(), settings.defaultIdentityId);
assertTrue(imported.getFile().isFile());
List<Bookmark> all = bookmarks.all();
assertEquals(3, all.size());
Bookmark lixko = all.get(1);
assertEquals("Lixko", lixko.label);
assertEquals("Bookmark Nick", lixko.nickname);
assertEquals("hunter2", lixko.password);
assertEquals("Lobby/Games", lixko.channel);
assertEquals("chanpw", lixko.channelPassword);
assertEquals(imported.getId(), lixko.identityId);
assertTrue(lixko.connectOnStartup);
Bookmark byId = all.get(2);
assertEquals("/42", byId.channel);
assertEquals("SyncTester", byId.nickname, "the default identity's nickname");
assertEquals("", byId.identityId, "connects with whatever the default identity is");
assertEquals("SyncTester", settings.nickname);
// Running it again changes nothing.
result = importer.importBookmarks(SyncItemsTest.fixture());
assertEquals(0, result.identitiesAdded());
assertEquals(0, result.bookmarksAdded());
assertEquals(3, bookmarks.all().size());
assertEquals(1, identities.all().size());
}
@Test
void identityImportRecognisesAKnownKey() throws Exception {
Settings settings = new Settings();
IdentityStore identities = IdentityStore.load(settings);
int before = identities.all().size();
TeamSpeakImporter importer = new TeamSpeakImporter(identities, new Bookmarks(), settings);
TeamSpeakImporter.Result first = importer.importIdentities(SyncItemsTest.fixture());
TeamSpeakImporter.Result second = importer.importIdentities(SyncItemsTest.fixture());
assertEquals(1, first.identities());
assertEquals(1, second.identitiesKnown());
assertEquals(before + first.identitiesAdded(), identities.all().size());
assertNotNull(identities.byId(settings.defaultIdentityId));
}
}

View File

@@ -1,5 +1,8 @@
package com.ts3client.contacts;
package com.ts3client.teamspeak;
import com.ts3client.contacts.Contact;
import com.ts3client.contacts.ContactType;
import com.ts3client.contacts.NicknameDisplay;
import org.junit.jupiter.api.Test;
import java.io.File;
@@ -12,7 +15,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class TeamSpeakSettingsDbTest {
private static File fixture() {
return new File(TeamSpeakSettingsDbTest.class.getResource("/contacts/settings.db").getFile());
return new File(TeamSpeakSettingsDbTest.class.getResource("/teamspeak/settings.db").getFile());
}
@Test

Binary file not shown.