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

@@ -76,6 +76,14 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
- **Identity management** (Tools → Identities) — keep several identities, mark one - **Identity management** (Tools → Identities) — keep several identities, mark one
as the default, pick one per server or per bookmark, rename, raise an identity's as the default, pick one per server or per bookmark, rename, raise an identity's
security level, and import/export TeamSpeak-compatible `.ini` identity files. security level, and import/export TeamSpeak-compatible `.ini` identity files.
- **Import from TeamSpeak 3 / myTeamSpeak** — the official client keeps its bookmarks
and identities (the data a myTeamSpeak account synchronises) as protobuf
`Item_Data` blobs in the `ProtobufItems` table of its `settings.db`. On first run,
when this client has no identity of its own yet, it adopts all of them — identities,
the default identity and its nickname, and the bookmarks with the identities they
connect with; "Import from TeamSpeak 3…" in Manage Bookmarks and in Identities
re-imports at any time (additive: known identities and bookmarks are left alone).
The schema was recovered from the descriptors embedded in the client binary.
- **Contacts** (Tools → Contacts) — TeamSpeak's friend/blocked list: mark a client as - **Contacts** (Tools → Contacts) — TeamSpeak's friend/blocked list: mark a client as
**Friend**, **Blocked** or **Neutral** from its context menu (or drag it into the **Friend**, **Blocked** or **Neutral** from its context menu (or drag it into the
window), give it a custom and a phonetic nickname, choose how the name is shown window), give it a custom and a phonetic nickname, choose how the name is shown
@@ -190,6 +198,12 @@ core/ com.ts3client
├── config.Settings persisted prefs (~/.ts3jclient/settings.properties) ├── config.Settings persisted prefs (~/.ts3jclient/settings.properties)
├── config.Bookmarks persisted server bookmarks (with per-server identity) ├── config.Bookmarks persisted server bookmarks (with per-server identity)
├── config.IdentityStore managed identities (~/.ts3jclient/identities/*.ini) ├── config.IdentityStore managed identities (~/.ts3jclient/identities/*.ini)
├── teamspeak the official client's own data files
│ ├── TeamSpeakSettingsDb locates settings.db; reads Contacts and the ProtobufItems sync store
│ ├── SqliteReader minimal read-only SQLite 3 file reader (no driver)
│ ├── SyncItem a synced bookmark / identity / folder (Item_Data)
│ ├── SyncItemDecoder protobuf wire decoding of Item_Data, via ProtobufReader
│ └── TeamSpeakImporter merges synced items into IdentityStore / Bookmarks
├── audio abstractions + reusable DSP (no platform code) ├── audio abstractions + reusable DSP (no platform code)
│ ├── AudioBackend factory for a platform's VoiceInput/VoiceOutput │ ├── AudioBackend factory for a platform's VoiceInput/VoiceOutput
│ ├── VoiceInput capture source (extends ts3j Microphone) + gating controls │ ├── VoiceInput capture source (extends ts3j Microphone) + gating controls

View File

@@ -3,6 +3,7 @@ package com.ts3client.config;
import com.github.manevolent.ts3j.identity.LocalIdentity; import com.github.manevolent.ts3j.identity.LocalIdentity;
import org.ini4j.Ini; import org.ini4j.Ini;
import java.io.ByteArrayInputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@@ -90,6 +91,15 @@ public final class IdentityStore {
return null; 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 * Resolves the identity to connect with: the requested one, else the configured
* default, else the first available — generating one when the store is empty. * 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); 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. */ /** Writes an identity out in TeamSpeak's own format, ready to import elsewhere. */
public void exportTo(IdentityEntry entry, File target) throws IOException { public void exportTo(IdentityEntry entry, File target) throws IOException {
write(entry.getIdentity(), entry.getName(), target); write(entry.getIdentity(), entry.getName(), target);

View File

@@ -1,5 +1,7 @@
package com.ts3client.contacts; package com.ts3client.contacts;
import com.ts3client.teamspeak.TeamSpeakSettingsDb;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; 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.IOException;
import java.io.RandomAccessFile; 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 org.junit.jupiter.api.Test;
import java.io.File; import java.io.File;
@@ -12,7 +15,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class TeamSpeakSettingsDbTest { class TeamSpeakSettingsDbTest {
private static File fixture() { 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 @Test

Binary file not shown.

View File

@@ -3,6 +3,9 @@ package com.ts3client.ui;
import com.ts3client.config.Bookmark; import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks; import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore; import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings;
import com.ts3client.teamspeak.TeamSpeakImporter;
import com.ts3client.teamspeak.TeamSpeakSettingsDb;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.Box; import javax.swing.Box;
@@ -26,6 +29,7 @@ import java.awt.Frame;
import java.awt.GridBagConstraints; import java.awt.GridBagConstraints;
import java.awt.GridBagLayout; import java.awt.GridBagLayout;
import java.awt.Insets; import java.awt.Insets;
import java.io.File;
import java.util.function.Consumer; import java.util.function.Consumer;
/** /**
@@ -36,6 +40,8 @@ import java.util.function.Consumer;
public final class BookmarksDialog extends JDialog { public final class BookmarksDialog extends JDialog {
private final Bookmarks bookmarks; private final Bookmarks bookmarks;
private final IdentityStore identities;
private final Settings settings;
private final Consumer<Bookmark> onConnect; private final Consumer<Bookmark> onConnect;
private final Runnable onChanged; private final Runnable onChanged;
private final DefaultListModel<Bookmark> listModel = new DefaultListModel<>(); private final DefaultListModel<Bookmark> listModel = new DefaultListModel<>();
@@ -56,10 +62,12 @@ public final class BookmarksDialog extends JDialog {
/** Bookmark the form currently shows, so edits can be flushed back into it. */ /** Bookmark the form currently shows, so edits can be flushed back into it. */
private Bookmark editing; private Bookmark editing;
public BookmarksDialog(Frame owner, Bookmarks bookmarks, IdentityStore identities, public BookmarksDialog(Frame owner, Bookmarks bookmarks, IdentityStore identities, Settings settings,
Consumer<Bookmark> onConnect, Runnable onChanged) { Consumer<Bookmark> onConnect, Runnable onChanged) {
super(owner, "Manage Bookmarks", true); super(owner, "Manage Bookmarks", true);
this.bookmarks = bookmarks; this.bookmarks = bookmarks;
this.identities = identities;
this.settings = settings;
this.onConnect = onConnect; this.onConnect = onConnect;
this.onChanged = onChanged; this.onChanged = onChanged;
this.identity = new IdentityChooser(identities, true, ""); this.identity = new IdentityChooser(identities, true, "");
@@ -151,17 +159,23 @@ public final class BookmarksDialog extends JDialog {
buttons.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8)); buttons.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8));
addButton(buttons, "Add", this::addBookmark); addButton(buttons, "Add", this::addBookmark);
addButton(buttons, "Remove", this::removeSelected); addButton(buttons, "Remove", this::removeSelected);
if (TeamSpeakSettingsDb.locate() != null) {
addButton(buttons, "Import from TeamSpeak 3…", this::importFromTeamSpeak)
.setToolTipText("Add the bookmarks of the TeamSpeak 3 client installed on this computer, "
+ "along with the identities they connect with");
}
buttons.add(Box.createHorizontalGlue()); buttons.add(Box.createHorizontalGlue());
addButton(buttons, "Connect", this::connectSelected); addButton(buttons, "Connect", this::connectSelected);
addButton(buttons, "Close", this::closeDialog); addButton(buttons, "Close", this::closeDialog);
return buttons; return buttons;
} }
private void addButton(JPanel panel, String text, Runnable action) { private JButton addButton(JPanel panel, String text, Runnable action) {
JButton b = new JButton(text); JButton b = new JButton(text);
b.addActionListener(e -> action.run()); b.addActionListener(e -> action.run());
panel.add(b); panel.add(b);
panel.add(Box.createHorizontalStrut(4)); panel.add(Box.createHorizontalStrut(4));
return b;
} }
// ---- Form <-> model ---- // ---- Form <-> model ----
@@ -228,6 +242,26 @@ public final class BookmarksDialog extends JDialog {
persist(); persist();
} }
private void importFromTeamSpeak() {
File db = TeamSpeakSettingsDb.locate();
if (db == null) return;
commit();
try {
TeamSpeakImporter.Result result = new TeamSpeakImporter(identities, bookmarks, settings).importBookmarks(db);
identity.reload();
reload();
if (result.bookmarksAdded() > 0) list.setSelectedIndex(listModel.size() - result.bookmarksAdded());
persist();
JOptionPane.showMessageDialog(this, result.bookmarks() + " bookmark(s) read from " + db
+ "\n" + result.bookmarksAdded() + " new, " + result.bookmarksKnown() + " already present; "
+ result.identitiesAdded() + " identit" + (result.identitiesAdded() == 1 ? "y" : "ies") + " imported.",
"Import from TeamSpeak 3", JOptionPane.INFORMATION_MESSAGE);
} catch (Exception e) {
JOptionPane.showMessageDialog(this, "Could not read " + db + ":\n" + e.getMessage(),
"Import from TeamSpeak 3", JOptionPane.ERROR_MESSAGE);
}
}
private void connectSelected() { private void connectSelected() {
commit(); commit();
Bookmark b = list.getSelectedValue(); Bookmark b = list.getSelectedValue();

View File

@@ -5,7 +5,7 @@ import com.ts3client.contacts.Contact;
import com.ts3client.contacts.ContactStore; import com.ts3client.contacts.ContactStore;
import com.ts3client.contacts.ContactType; import com.ts3client.contacts.ContactType;
import com.ts3client.contacts.NicknameDisplay; import com.ts3client.contacts.NicknameDisplay;
import com.ts3client.contacts.TeamSpeakSettingsDb; import com.ts3client.teamspeak.TeamSpeakSettingsDb;
import com.ts3client.text.TsLink; import com.ts3client.text.TsLink;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;

View File

@@ -4,6 +4,8 @@ import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityEntry; import com.ts3client.config.IdentityEntry;
import com.ts3client.config.IdentityStore; import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.teamspeak.TeamSpeakImporter;
import com.ts3client.teamspeak.TeamSpeakSettingsDb;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.Box; import javax.swing.Box;
@@ -79,6 +81,9 @@ public final class IdentitiesDialog extends JDialog {
buttons.setBorder(BorderFactory.createEmptyBorder(8, 0, 8, 8)); buttons.setBorder(BorderFactory.createEmptyBorder(8, 0, 8, 8));
addButton(buttons, "New…", this::newIdentity); addButton(buttons, "New…", this::newIdentity);
addButton(buttons, "Import…", this::importIdentity); addButton(buttons, "Import…", this::importIdentity);
if (TeamSpeakSettingsDb.locate() != null) {
addButton(buttons, "Import from TeamSpeak 3…", this::importFromTeamSpeak);
}
addButton(buttons, "Export…", this::exportSelected); addButton(buttons, "Export…", this::exportSelected);
addButton(buttons, "Rename…", this::renameSelected); addButton(buttons, "Rename…", this::renameSelected);
addButton(buttons, "Set as default", this::setSelectedDefault); addButton(buttons, "Set as default", this::setSelectedDefault);
@@ -209,6 +214,21 @@ public final class IdentitiesDialog extends JDialog {
} }
} }
private void importFromTeamSpeak() {
File db = TeamSpeakSettingsDb.locate();
if (db == null) return;
try {
TeamSpeakImporter.Result result = new TeamSpeakImporter(identities, bookmarks, settings).importIdentities(db);
reload();
JOptionPane.showMessageDialog(this, result.identities() + " identit" + (result.identities() == 1 ? "y" : "ies")
+ " read from " + db + "\n" + result.identitiesAdded() + " new, "
+ result.identitiesKnown() + " already present.",
"Import from TeamSpeak 3", JOptionPane.INFORMATION_MESSAGE);
} catch (Exception e) {
error("Could not read " + db, e);
}
}
private void exportSelected() { private void exportSelected() {
IdentityEntry e = list.getSelectedValue(); IdentityEntry e = list.getSelectedValue();
if (e == null) return; if (e == null) return;

View File

@@ -23,9 +23,7 @@ final class IdentityChooser extends JComboBox<Object> {
IdentityChooser(IdentityStore identities, boolean allowDefault, String selectedId) { IdentityChooser(IdentityStore identities, boolean allowDefault, String selectedId) {
this.identities = identities; this.identities = identities;
this.allowDefault = allowDefault; this.allowDefault = allowDefault;
if (allowDefault) addItem(USE_DEFAULT); reload();
for (IdentityEntry e : identities.all()) addItem(e);
setSelectedIdentityId(selectedId); setSelectedIdentityId(selectedId);
setRenderer(new DefaultListCellRenderer() { setRenderer(new DefaultListCellRenderer() {
@@ -40,6 +38,15 @@ final class IdentityChooser extends JComboBox<Object> {
}); });
} }
/** Re-reads the store's identities, keeping the selection where it still exists. */
void reload() {
String selected = getSelectedIdentityId();
removeAllItems();
if (allowDefault) addItem(USE_DEFAULT);
for (IdentityEntry e : identities.all()) addItem(e);
setSelectedIdentityId(selected);
}
/** Selected identity id, or an empty string when the default is to be used. */ /** Selected identity id, or an empty string when the default is to be used. */
String getSelectedIdentityId() { String getSelectedIdentityId() {
Object v = getSelectedItem(); Object v = getSelectedItem();

View File

@@ -8,6 +8,7 @@ import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks; import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore; import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.teamspeak.TeamSpeakImporter;
import com.ts3client.contacts.ContactStore; import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundNotifier;
@@ -85,6 +86,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
super("TS3J — TeamSpeak 3 Java Client"); super("TS3J — TeamSpeak 3 Java Client");
this.settings = settings; this.settings = settings;
this.identities = IdentityStore.load(settings); this.identities = IdentityStore.load(settings);
new TeamSpeakImporter(identities, bookmarks, settings).seedIfFresh();
this.sounds = new SoundNotifier(settings); this.sounds = new SoundNotifier(settings);
this.soundPlayer = audio.createSoundPlayer(settings); this.soundPlayer = audio.createSoundPlayer(settings);
this.sounds.setPlayer(soundPlayer); this.sounds.setPlayer(soundPlayer);
@@ -235,7 +237,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
@Override @Override
public void onManageBookmarks() { public void onManageBookmarks() {
new BookmarksDialog(MainFrame.this, bookmarks, identities, new BookmarksDialog(MainFrame.this, bookmarks, identities, settings,
MainFrame.this::connectToBookmark, menuBar::rebuildBookmarks).setVisible(true); MainFrame.this::connectToBookmark, menuBar::rebuildBookmarks).setVisible(true);
} }
}); });