From dbb425403a86b817d5bb424ecfadf860959fade1 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 23 Sep 2026 19:02:52 +0000 Subject: [PATCH] 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 --- ts3-client/README.md | 14 ++ .../com/ts3client/config/IdentityStore.java | 24 +++ .../com/ts3client/contacts/ContactStore.java | 2 + .../contacts/TeamSpeakSettingsDb.java | 66 ------- .../ts3client/teamspeak/ProtobufReader.java | 107 +++++++++++ .../{contacts => teamspeak}/SqliteReader.java | 2 +- .../com/ts3client/teamspeak/SyncItem.java | 38 ++++ .../ts3client/teamspeak/SyncItemDecoder.java | 102 ++++++++++ .../teamspeak/TeamSpeakImporter.java | 175 ++++++++++++++++++ .../teamspeak/TeamSpeakSettingsDb.java | 100 ++++++++++ .../ts3client/teamspeak/SyncItemsTest.java | 82 ++++++++ .../teamspeak/TeamSpeakImporterTest.java | 85 +++++++++ .../TeamSpeakSettingsDbTest.java | 7 +- .../{contacts => teamspeak}/settings.db | Bin .../core/src/test/resources/teamspeak/sync.db | Bin 0 -> 4096 bytes .../com/ts3client/ui/BookmarksDialog.java | 38 +++- .../java/com/ts3client/ui/ContactsDialog.java | 2 +- .../com/ts3client/ui/IdentitiesDialog.java | 20 ++ .../com/ts3client/ui/IdentityChooser.java | 13 +- .../main/java/com/ts3client/ui/MainFrame.java | 4 +- 20 files changed, 805 insertions(+), 76 deletions(-) delete mode 100644 ts3-client/core/src/main/java/com/ts3client/contacts/TeamSpeakSettingsDb.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/teamspeak/ProtobufReader.java rename ts3-client/core/src/main/java/com/ts3client/{contacts => teamspeak}/SqliteReader.java (99%) create mode 100644 ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItem.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItemDecoder.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakImporter.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakSettingsDb.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/teamspeak/SyncItemsTest.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakImporterTest.java rename ts3-client/core/src/test/java/com/ts3client/{contacts => teamspeak}/TeamSpeakSettingsDbTest.java (92%) rename ts3-client/core/src/test/resources/{contacts => teamspeak}/settings.db (100%) create mode 100644 ts3-client/core/src/test/resources/teamspeak/sync.db diff --git a/ts3-client/README.md b/ts3-client/README.md index f151276..8dd6209 100644 --- a/ts3-client/README.md +++ b/ts3-client/README.md @@ -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 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. +- **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 **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 @@ -190,6 +198,12 @@ core/ com.ts3client ├── config.Settings persisted prefs (~/.ts3jclient/settings.properties) ├── config.Bookmarks persisted server bookmarks (with per-server identity) ├── 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) │ ├── AudioBackend factory for a platform's VoiceInput/VoiceOutput │ ├── VoiceInput capture source (extends ts3j Microphone) + gating controls diff --git a/ts3-client/core/src/main/java/com/ts3client/config/IdentityStore.java b/ts3-client/core/src/main/java/com/ts3client/config/IdentityStore.java index 2b8893b..f0d3161 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/IdentityStore.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/IdentityStore.java @@ -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 V} 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); diff --git a/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java b/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java index a782fc0..2a4832f 100644 --- a/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java +++ b/ts3-client/core/src/main/java/com/ts3client/contacts/ContactStore.java @@ -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; diff --git a/ts3-client/core/src/main/java/com/ts3client/contacts/TeamSpeakSettingsDb.java b/ts3-client/core/src/main/java/com/ts3client/contacts/TeamSpeakSettingsDb.java deleted file mode 100644 index 563b5f4..0000000 --- a/ts3-client/core/src/main/java/com/ts3client/contacts/TeamSpeakSettingsDb.java +++ /dev/null @@ -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 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 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 readContacts(File db) throws java.io.IOException { - List 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; - } -} diff --git a/ts3-client/core/src/main/java/com/ts3client/teamspeak/ProtobufReader.java b/ts3-client/core/src/main/java/com/ts3client/teamspeak/ProtobufReader.java new file mode 100644 index 0000000..651ea07 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/teamspeak/ProtobufReader.java @@ -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; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/contacts/SqliteReader.java b/ts3-client/core/src/main/java/com/ts3client/teamspeak/SqliteReader.java similarity index 99% rename from ts3-client/core/src/main/java/com/ts3client/contacts/SqliteReader.java rename to ts3-client/core/src/main/java/com/ts3client/teamspeak/SqliteReader.java index ec52622..614bbd9 100644 --- a/ts3-client/core/src/main/java/com/ts3client/contacts/SqliteReader.java +++ b/ts3-client/core/src/main/java/com/ts3client/teamspeak/SqliteReader.java @@ -1,4 +1,4 @@ -package com.ts3client.contacts; +package com.ts3client.teamspeak; import java.io.IOException; import java.io.RandomAccessFile; diff --git a/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItem.java b/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItem.java new file mode 100644 index 0000000..3483aef --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItem.java @@ -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 V} 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 { + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItemDecoder.java b/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItemDecoder.java new file mode 100644 index 0000000..a35ac53 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItemDecoder.java @@ -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); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakImporter.java b/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakImporter.java new file mode 100644 index 0000000..c312d7a --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakImporter.java @@ -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 items, boolean withBookmarks, boolean onlyUsedIdentities) + throws IOException { + Map syncIdentities = new LinkedHashMap<>(); + List 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 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 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(); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakSettingsDb.java b/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakSettingsDb.java new file mode 100644 index 0000000..8c2c80a --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakSettingsDb.java @@ -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 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 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 readContacts(File db) throws IOException { + List 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 readSyncItems(File db) throws IOException { + List 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 columnValues(File db, String tableName) throws IOException { + List 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; + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/teamspeak/SyncItemsTest.java b/ts3-client/core/src/test/java/com/ts3client/teamspeak/SyncItemsTest.java new file mode 100644 index 0000000..ff0e3a4 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/teamspeak/SyncItemsTest.java @@ -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 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 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 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()); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakImporterTest.java b/ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakImporterTest.java new file mode 100644 index 0000000..7accf71 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakImporterTest.java @@ -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 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)); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/contacts/TeamSpeakSettingsDbTest.java b/ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakSettingsDbTest.java similarity index 92% rename from ts3-client/core/src/test/java/com/ts3client/contacts/TeamSpeakSettingsDbTest.java rename to ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakSettingsDbTest.java index 871b28e..58ff690 100644 --- a/ts3-client/core/src/test/java/com/ts3client/contacts/TeamSpeakSettingsDbTest.java +++ b/ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakSettingsDbTest.java @@ -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 diff --git a/ts3-client/core/src/test/resources/contacts/settings.db b/ts3-client/core/src/test/resources/teamspeak/settings.db similarity index 100% rename from ts3-client/core/src/test/resources/contacts/settings.db rename to ts3-client/core/src/test/resources/teamspeak/settings.db diff --git a/ts3-client/core/src/test/resources/teamspeak/sync.db b/ts3-client/core/src/test/resources/teamspeak/sync.db new file mode 100644 index 0000000000000000000000000000000000000000..78b71472d35a1980927aa8715118ab7997199fa4 GIT binary patch literal 4096 zcmeHKPly{;7=JJSc6Z!$HATBfTZR(YS~_vw%p{o<1e0u%O~_8ONoLc{rOZrTnlzJ{ zG&9*|7ZJ17f<1{|M2mPX9z@Soyy`(bNKYcg|0om@rQpT)vPpFdE$Kyz_BS)XdEYm0 z$oIbA_dX`&^+L>1SY5dXOh%*>G`1p_uB8)SXwN$wyiAQWiWu@8TRQA6O7hGQ`;IJ1HqTHkb8*oGvs zp+X}=B(gC$jo=QVE{drm^N%9CLL<8heY0>0QvD&g3h4P@W4G)jd3pH=&5IZe#iJ2P z7L5icAilxX>w>^xj2fIMM&d~4iDH^Gbza~oFE?mjF2kQ7^BIis8|HaS$Yx}D%i;NA zV>6>Aw`-f)PW5!WomI@5l(yQ=dZ`{UGHV6>>>yJ{&BRL6sprbd_NrCe$*<&#!%52(YQ<2N4Emg}(H7mMnYNpGpRZWei9Yb)Cp~hA9bj?v!GpAQKQUXJz{#n$AlZH+G2e%eGV>`@vsefBws6l0`F*~_5-p)g+uS(y>)ZI!(YLN& zi+ue2C)(+EUI{%pZ2Cxa7gy;#DHy^U5_kh6PLIl@Lx@P?q$tK5Mp0eV8;BPrVd_!? ze>I0)_#$K;t6;5D?qaRY7W=x{GJ*%T?3RJUF7Z3mA2#gH(naMcS-&uPI}eJWHt84o zWN>b(KGOv|;4%cSLhuFz@4>PDhX4$LE8x(6`Cpi$8o4A3-^;;JeT;i(B(WZ%5x75% zcHu$Bm$fY%oIguAj0fZc*~DbUL;oxZVAn$e_%0R<>0{g@0C7|#_Z`3yW+A!F8cofz zu*tUS!CAzI=@D6aNhuy5ZqCqhI8NgDHab-tv>Zl>n5M8N#kvc7jK9ztwC&*HUb%)% zYK3v1%D<4b?RH1&wOOTQw1ue};K`Y0-y%C*NIp`qb$$DptVaHUc=J0YT1F A_5c6? literal 0 HcmV?d00001 diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java index add7b19..933ef34 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java @@ -3,6 +3,9 @@ package com.ts3client.ui; import com.ts3client.config.Bookmark; import com.ts3client.config.Bookmarks; 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.Box; @@ -26,6 +29,7 @@ import java.awt.Frame; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.Insets; +import java.io.File; import java.util.function.Consumer; /** @@ -36,6 +40,8 @@ import java.util.function.Consumer; public final class BookmarksDialog extends JDialog { private final Bookmarks bookmarks; + private final IdentityStore identities; + private final Settings settings; private final Consumer onConnect; private final Runnable onChanged; private final DefaultListModel 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. */ private Bookmark editing; - public BookmarksDialog(Frame owner, Bookmarks bookmarks, IdentityStore identities, + public BookmarksDialog(Frame owner, Bookmarks bookmarks, IdentityStore identities, Settings settings, Consumer onConnect, Runnable onChanged) { super(owner, "Manage Bookmarks", true); this.bookmarks = bookmarks; + this.identities = identities; + this.settings = settings; this.onConnect = onConnect; this.onChanged = onChanged; this.identity = new IdentityChooser(identities, true, ""); @@ -151,17 +159,23 @@ public final class BookmarksDialog extends JDialog { buttons.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8)); addButton(buttons, "Add", this::addBookmark); 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()); addButton(buttons, "Connect", this::connectSelected); addButton(buttons, "Close", this::closeDialog); 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); b.addActionListener(e -> action.run()); panel.add(b); panel.add(Box.createHorizontalStrut(4)); + return b; } // ---- Form <-> model ---- @@ -228,6 +242,26 @@ public final class BookmarksDialog extends JDialog { 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() { commit(); Bookmark b = list.getSelectedValue(); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsDialog.java index 43c8f55..8df73e1 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsDialog.java @@ -5,7 +5,7 @@ import com.ts3client.contacts.Contact; import com.ts3client.contacts.ContactStore; import com.ts3client.contacts.ContactType; import com.ts3client.contacts.NicknameDisplay; -import com.ts3client.contacts.TeamSpeakSettingsDb; +import com.ts3client.teamspeak.TeamSpeakSettingsDb; import com.ts3client.text.TsLink; import javax.swing.BorderFactory; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IdentitiesDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IdentitiesDialog.java index 252b477..1ccf863 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/IdentitiesDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IdentitiesDialog.java @@ -4,6 +4,8 @@ import com.ts3client.config.Bookmarks; import com.ts3client.config.IdentityEntry; 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.Box; @@ -79,6 +81,9 @@ public final class IdentitiesDialog extends JDialog { buttons.setBorder(BorderFactory.createEmptyBorder(8, 0, 8, 8)); addButton(buttons, "New…", this::newIdentity); addButton(buttons, "Import…", this::importIdentity); + if (TeamSpeakSettingsDb.locate() != null) { + addButton(buttons, "Import from TeamSpeak 3…", this::importFromTeamSpeak); + } addButton(buttons, "Export…", this::exportSelected); addButton(buttons, "Rename…", this::renameSelected); 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() { IdentityEntry e = list.getSelectedValue(); if (e == null) return; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IdentityChooser.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IdentityChooser.java index e1ca94c..b7a4846 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/IdentityChooser.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IdentityChooser.java @@ -23,9 +23,7 @@ final class IdentityChooser extends JComboBox { IdentityChooser(IdentityStore identities, boolean allowDefault, String selectedId) { this.identities = identities; this.allowDefault = allowDefault; - if (allowDefault) addItem(USE_DEFAULT); - for (IdentityEntry e : identities.all()) addItem(e); - + reload(); setSelectedIdentityId(selectedId); setRenderer(new DefaultListCellRenderer() { @@ -40,6 +38,15 @@ final class IdentityChooser extends JComboBox { }); } + /** 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. */ String getSelectedIdentityId() { Object v = getSelectedItem(); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 9e0b1e8..0fe6066 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -8,6 +8,7 @@ import com.ts3client.config.Bookmark; import com.ts3client.config.Bookmarks; import com.ts3client.config.IdentityStore; import com.ts3client.config.Settings; +import com.ts3client.teamspeak.TeamSpeakImporter; import com.ts3client.contacts.ContactStore; import com.ts3client.net.ChannelNode; import com.ts3client.sound.SoundNotifier; @@ -85,6 +86,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { super("TS3J — TeamSpeak 3 Java Client"); this.settings = settings; this.identities = IdentityStore.load(settings); + new TeamSpeakImporter(identities, bookmarks, settings).seedIfFresh(); this.sounds = new SoundNotifier(settings); this.soundPlayer = audio.createSoundPlayer(settings); this.sounds.setPlayer(soundPlayer); @@ -235,7 +237,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { @Override public void onManageBookmarks() { - new BookmarksDialog(MainFrame.this, bookmarks, identities, + new BookmarksDialog(MainFrame.this, bookmarks, identities, settings, MainFrame.this::connectToBookmark, menuBar::rebuildBookmarks).setVisible(true); } });