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:
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ts3client.config;
|
||||
import com.github.manevolent.ts3j.identity.LocalIdentity;
|
||||
import org.ini4j.Ini;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -90,6 +91,15 @@ public final class IdentityStore {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The identity with this TeamSpeak unique ID, or {@code null}. */
|
||||
public IdentityEntry byUniqueId(String uniqueId) {
|
||||
if (uniqueId == null || uniqueId.isBlank()) return null;
|
||||
for (IdentityEntry e : entries) {
|
||||
if (e.getUniqueId().equals(uniqueId)) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the identity to connect with: the requested one, else the configured
|
||||
* default, else the first available — generating one when the store is empty.
|
||||
@@ -126,6 +136,20 @@ public final class IdentityStore {
|
||||
return create((name == null || name.isBlank()) ? "Imported identity" : name, identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an identity in the {@code <offset>V<obfuscated key>} form TeamSpeak keeps
|
||||
* in its INI files and its settings database.
|
||||
*/
|
||||
public static LocalIdentity parseIdentityString(String identity) throws IOException {
|
||||
String ini = "[Identity]\r\nidentity=\"" + identity + "\"\r\n";
|
||||
return LocalIdentity.read(new ByteArrayInputStream(ini.getBytes(StandardCharsets.US_ASCII)));
|
||||
}
|
||||
|
||||
/** Files an existing key pair as a managed identity under the given name. */
|
||||
public IdentityEntry add(String name, LocalIdentity identity) throws IOException {
|
||||
return create(name, identity);
|
||||
}
|
||||
|
||||
/** Writes an identity out in TeamSpeak's own format, ready to import elsewhere. */
|
||||
public void exportTo(IdentityEntry entry, File target) throws IOException {
|
||||
write(entry.getIdentity(), entry.getName(), target);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.ts3client.contacts;
|
||||
package com.ts3client.teamspeak;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
BIN
ts3-client/core/src/test/resources/teamspeak/sync.db
Normal file
BIN
ts3-client/core/src/test/resources/teamspeak/sync.db
Normal file
Binary file not shown.
@@ -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<Bookmark> onConnect;
|
||||
private final Runnable onChanged;
|
||||
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. */
|
||||
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) {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,9 +23,7 @@ final class IdentityChooser extends JComboBox<Object> {
|
||||
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<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. */
|
||||
String getSelectedIdentityId() {
|
||||
Object v = getSelectedItem();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user