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
|
- **Identity management** (Tools → Identities) — keep several identities, mark one
|
||||||
as the default, pick one per server or per bookmark, rename, raise an identity's
|
as the default, pick one per server or per bookmark, rename, raise an identity's
|
||||||
security level, and import/export TeamSpeak-compatible `.ini` identity files.
|
security level, and import/export TeamSpeak-compatible `.ini` identity files.
|
||||||
|
- **Import from TeamSpeak 3 / myTeamSpeak** — the official client keeps its bookmarks
|
||||||
|
and identities (the data a myTeamSpeak account synchronises) as protobuf
|
||||||
|
`Item_Data` blobs in the `ProtobufItems` table of its `settings.db`. On first run,
|
||||||
|
when this client has no identity of its own yet, it adopts all of them — identities,
|
||||||
|
the default identity and its nickname, and the bookmarks with the identities they
|
||||||
|
connect with; "Import from TeamSpeak 3…" in Manage Bookmarks and in Identities
|
||||||
|
re-imports at any time (additive: known identities and bookmarks are left alone).
|
||||||
|
The schema was recovered from the descriptors embedded in the client binary.
|
||||||
- **Contacts** (Tools → Contacts) — TeamSpeak's friend/blocked list: mark a client as
|
- **Contacts** (Tools → Contacts) — TeamSpeak's friend/blocked list: mark a client as
|
||||||
**Friend**, **Blocked** or **Neutral** from its context menu (or drag it into the
|
**Friend**, **Blocked** or **Neutral** from its context menu (or drag it into the
|
||||||
window), give it a custom and a phonetic nickname, choose how the name is shown
|
window), give it a custom and a phonetic nickname, choose how the name is shown
|
||||||
@@ -190,6 +198,12 @@ core/ com.ts3client
|
|||||||
├── config.Settings persisted prefs (~/.ts3jclient/settings.properties)
|
├── config.Settings persisted prefs (~/.ts3jclient/settings.properties)
|
||||||
├── config.Bookmarks persisted server bookmarks (with per-server identity)
|
├── config.Bookmarks persisted server bookmarks (with per-server identity)
|
||||||
├── config.IdentityStore managed identities (~/.ts3jclient/identities/*.ini)
|
├── config.IdentityStore managed identities (~/.ts3jclient/identities/*.ini)
|
||||||
|
├── teamspeak the official client's own data files
|
||||||
|
│ ├── TeamSpeakSettingsDb locates settings.db; reads Contacts and the ProtobufItems sync store
|
||||||
|
│ ├── SqliteReader minimal read-only SQLite 3 file reader (no driver)
|
||||||
|
│ ├── SyncItem a synced bookmark / identity / folder (Item_Data)
|
||||||
|
│ ├── SyncItemDecoder protobuf wire decoding of Item_Data, via ProtobufReader
|
||||||
|
│ └── TeamSpeakImporter merges synced items into IdentityStore / Bookmarks
|
||||||
├── audio abstractions + reusable DSP (no platform code)
|
├── audio abstractions + reusable DSP (no platform code)
|
||||||
│ ├── AudioBackend factory for a platform's VoiceInput/VoiceOutput
|
│ ├── AudioBackend factory for a platform's VoiceInput/VoiceOutput
|
||||||
│ ├── VoiceInput capture source (extends ts3j Microphone) + gating controls
|
│ ├── VoiceInput capture source (extends ts3j Microphone) + gating controls
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.ts3client.config;
|
|||||||
import com.github.manevolent.ts3j.identity.LocalIdentity;
|
import com.github.manevolent.ts3j.identity.LocalIdentity;
|
||||||
import org.ini4j.Ini;
|
import org.ini4j.Ini;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
@@ -90,6 +91,15 @@ public final class IdentityStore {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The identity with this TeamSpeak unique ID, or {@code null}. */
|
||||||
|
public IdentityEntry byUniqueId(String uniqueId) {
|
||||||
|
if (uniqueId == null || uniqueId.isBlank()) return null;
|
||||||
|
for (IdentityEntry e : entries) {
|
||||||
|
if (e.getUniqueId().equals(uniqueId)) return e;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the identity to connect with: the requested one, else the configured
|
* Resolves the identity to connect with: the requested one, else the configured
|
||||||
* default, else the first available — generating one when the store is empty.
|
* default, else the first available — generating one when the store is empty.
|
||||||
@@ -126,6 +136,20 @@ public final class IdentityStore {
|
|||||||
return create((name == null || name.isBlank()) ? "Imported identity" : name, identity);
|
return create((name == null || name.isBlank()) ? "Imported identity" : name, identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an identity in the {@code <offset>V<obfuscated key>} form TeamSpeak keeps
|
||||||
|
* in its INI files and its settings database.
|
||||||
|
*/
|
||||||
|
public static LocalIdentity parseIdentityString(String identity) throws IOException {
|
||||||
|
String ini = "[Identity]\r\nidentity=\"" + identity + "\"\r\n";
|
||||||
|
return LocalIdentity.read(new ByteArrayInputStream(ini.getBytes(StandardCharsets.US_ASCII)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Files an existing key pair as a managed identity under the given name. */
|
||||||
|
public IdentityEntry add(String name, LocalIdentity identity) throws IOException {
|
||||||
|
return create(name, identity);
|
||||||
|
}
|
||||||
|
|
||||||
/** Writes an identity out in TeamSpeak's own format, ready to import elsewhere. */
|
/** Writes an identity out in TeamSpeak's own format, ready to import elsewhere. */
|
||||||
public void exportTo(IdentityEntry entry, File target) throws IOException {
|
public void exportTo(IdentityEntry entry, File target) throws IOException {
|
||||||
write(entry.getIdentity(), entry.getName(), target);
|
write(entry.getIdentity(), entry.getName(), target);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.ts3client.contacts;
|
package com.ts3client.contacts;
|
||||||
|
|
||||||
|
import com.ts3client.teamspeak.TeamSpeakSettingsDb;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|||||||
@@ -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.IOException;
|
||||||
import java.io.RandomAccessFile;
|
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 org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
@@ -12,7 +15,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
class TeamSpeakSettingsDbTest {
|
class TeamSpeakSettingsDbTest {
|
||||||
|
|
||||||
private static File fixture() {
|
private static File fixture() {
|
||||||
return new File(TeamSpeakSettingsDbTest.class.getResource("/contacts/settings.db").getFile());
|
return new File(TeamSpeakSettingsDbTest.class.getResource("/teamspeak/settings.db").getFile());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
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.Bookmark;
|
||||||
import com.ts3client.config.Bookmarks;
|
import com.ts3client.config.Bookmarks;
|
||||||
import com.ts3client.config.IdentityStore;
|
import com.ts3client.config.IdentityStore;
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
import com.ts3client.teamspeak.TeamSpeakImporter;
|
||||||
|
import com.ts3client.teamspeak.TeamSpeakSettingsDb;
|
||||||
|
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
import javax.swing.Box;
|
import javax.swing.Box;
|
||||||
@@ -26,6 +29,7 @@ import java.awt.Frame;
|
|||||||
import java.awt.GridBagConstraints;
|
import java.awt.GridBagConstraints;
|
||||||
import java.awt.GridBagLayout;
|
import java.awt.GridBagLayout;
|
||||||
import java.awt.Insets;
|
import java.awt.Insets;
|
||||||
|
import java.io.File;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +40,8 @@ import java.util.function.Consumer;
|
|||||||
public final class BookmarksDialog extends JDialog {
|
public final class BookmarksDialog extends JDialog {
|
||||||
|
|
||||||
private final Bookmarks bookmarks;
|
private final Bookmarks bookmarks;
|
||||||
|
private final IdentityStore identities;
|
||||||
|
private final Settings settings;
|
||||||
private final Consumer<Bookmark> onConnect;
|
private final Consumer<Bookmark> onConnect;
|
||||||
private final Runnable onChanged;
|
private final Runnable onChanged;
|
||||||
private final DefaultListModel<Bookmark> listModel = new DefaultListModel<>();
|
private final DefaultListModel<Bookmark> listModel = new DefaultListModel<>();
|
||||||
@@ -56,10 +62,12 @@ public final class BookmarksDialog extends JDialog {
|
|||||||
/** Bookmark the form currently shows, so edits can be flushed back into it. */
|
/** Bookmark the form currently shows, so edits can be flushed back into it. */
|
||||||
private Bookmark editing;
|
private Bookmark editing;
|
||||||
|
|
||||||
public BookmarksDialog(Frame owner, Bookmarks bookmarks, IdentityStore identities,
|
public BookmarksDialog(Frame owner, Bookmarks bookmarks, IdentityStore identities, Settings settings,
|
||||||
Consumer<Bookmark> onConnect, Runnable onChanged) {
|
Consumer<Bookmark> onConnect, Runnable onChanged) {
|
||||||
super(owner, "Manage Bookmarks", true);
|
super(owner, "Manage Bookmarks", true);
|
||||||
this.bookmarks = bookmarks;
|
this.bookmarks = bookmarks;
|
||||||
|
this.identities = identities;
|
||||||
|
this.settings = settings;
|
||||||
this.onConnect = onConnect;
|
this.onConnect = onConnect;
|
||||||
this.onChanged = onChanged;
|
this.onChanged = onChanged;
|
||||||
this.identity = new IdentityChooser(identities, true, "");
|
this.identity = new IdentityChooser(identities, true, "");
|
||||||
@@ -151,17 +159,23 @@ public final class BookmarksDialog extends JDialog {
|
|||||||
buttons.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8));
|
buttons.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8));
|
||||||
addButton(buttons, "Add", this::addBookmark);
|
addButton(buttons, "Add", this::addBookmark);
|
||||||
addButton(buttons, "Remove", this::removeSelected);
|
addButton(buttons, "Remove", this::removeSelected);
|
||||||
|
if (TeamSpeakSettingsDb.locate() != null) {
|
||||||
|
addButton(buttons, "Import from TeamSpeak 3…", this::importFromTeamSpeak)
|
||||||
|
.setToolTipText("Add the bookmarks of the TeamSpeak 3 client installed on this computer, "
|
||||||
|
+ "along with the identities they connect with");
|
||||||
|
}
|
||||||
buttons.add(Box.createHorizontalGlue());
|
buttons.add(Box.createHorizontalGlue());
|
||||||
addButton(buttons, "Connect", this::connectSelected);
|
addButton(buttons, "Connect", this::connectSelected);
|
||||||
addButton(buttons, "Close", this::closeDialog);
|
addButton(buttons, "Close", this::closeDialog);
|
||||||
return buttons;
|
return buttons;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addButton(JPanel panel, String text, Runnable action) {
|
private JButton addButton(JPanel panel, String text, Runnable action) {
|
||||||
JButton b = new JButton(text);
|
JButton b = new JButton(text);
|
||||||
b.addActionListener(e -> action.run());
|
b.addActionListener(e -> action.run());
|
||||||
panel.add(b);
|
panel.add(b);
|
||||||
panel.add(Box.createHorizontalStrut(4));
|
panel.add(Box.createHorizontalStrut(4));
|
||||||
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Form <-> model ----
|
// ---- Form <-> model ----
|
||||||
@@ -228,6 +242,26 @@ public final class BookmarksDialog extends JDialog {
|
|||||||
persist();
|
persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void importFromTeamSpeak() {
|
||||||
|
File db = TeamSpeakSettingsDb.locate();
|
||||||
|
if (db == null) return;
|
||||||
|
commit();
|
||||||
|
try {
|
||||||
|
TeamSpeakImporter.Result result = new TeamSpeakImporter(identities, bookmarks, settings).importBookmarks(db);
|
||||||
|
identity.reload();
|
||||||
|
reload();
|
||||||
|
if (result.bookmarksAdded() > 0) list.setSelectedIndex(listModel.size() - result.bookmarksAdded());
|
||||||
|
persist();
|
||||||
|
JOptionPane.showMessageDialog(this, result.bookmarks() + " bookmark(s) read from " + db
|
||||||
|
+ "\n" + result.bookmarksAdded() + " new, " + result.bookmarksKnown() + " already present; "
|
||||||
|
+ result.identitiesAdded() + " identit" + (result.identitiesAdded() == 1 ? "y" : "ies") + " imported.",
|
||||||
|
"Import from TeamSpeak 3", JOptionPane.INFORMATION_MESSAGE);
|
||||||
|
} catch (Exception e) {
|
||||||
|
JOptionPane.showMessageDialog(this, "Could not read " + db + ":\n" + e.getMessage(),
|
||||||
|
"Import from TeamSpeak 3", JOptionPane.ERROR_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void connectSelected() {
|
private void connectSelected() {
|
||||||
commit();
|
commit();
|
||||||
Bookmark b = list.getSelectedValue();
|
Bookmark b = list.getSelectedValue();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import com.ts3client.contacts.Contact;
|
|||||||
import com.ts3client.contacts.ContactStore;
|
import com.ts3client.contacts.ContactStore;
|
||||||
import com.ts3client.contacts.ContactType;
|
import com.ts3client.contacts.ContactType;
|
||||||
import com.ts3client.contacts.NicknameDisplay;
|
import com.ts3client.contacts.NicknameDisplay;
|
||||||
import com.ts3client.contacts.TeamSpeakSettingsDb;
|
import com.ts3client.teamspeak.TeamSpeakSettingsDb;
|
||||||
import com.ts3client.text.TsLink;
|
import com.ts3client.text.TsLink;
|
||||||
|
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.ts3client.config.Bookmarks;
|
|||||||
import com.ts3client.config.IdentityEntry;
|
import com.ts3client.config.IdentityEntry;
|
||||||
import com.ts3client.config.IdentityStore;
|
import com.ts3client.config.IdentityStore;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
|
import com.ts3client.teamspeak.TeamSpeakImporter;
|
||||||
|
import com.ts3client.teamspeak.TeamSpeakSettingsDb;
|
||||||
|
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
import javax.swing.Box;
|
import javax.swing.Box;
|
||||||
@@ -79,6 +81,9 @@ public final class IdentitiesDialog extends JDialog {
|
|||||||
buttons.setBorder(BorderFactory.createEmptyBorder(8, 0, 8, 8));
|
buttons.setBorder(BorderFactory.createEmptyBorder(8, 0, 8, 8));
|
||||||
addButton(buttons, "New…", this::newIdentity);
|
addButton(buttons, "New…", this::newIdentity);
|
||||||
addButton(buttons, "Import…", this::importIdentity);
|
addButton(buttons, "Import…", this::importIdentity);
|
||||||
|
if (TeamSpeakSettingsDb.locate() != null) {
|
||||||
|
addButton(buttons, "Import from TeamSpeak 3…", this::importFromTeamSpeak);
|
||||||
|
}
|
||||||
addButton(buttons, "Export…", this::exportSelected);
|
addButton(buttons, "Export…", this::exportSelected);
|
||||||
addButton(buttons, "Rename…", this::renameSelected);
|
addButton(buttons, "Rename…", this::renameSelected);
|
||||||
addButton(buttons, "Set as default", this::setSelectedDefault);
|
addButton(buttons, "Set as default", this::setSelectedDefault);
|
||||||
@@ -209,6 +214,21 @@ public final class IdentitiesDialog extends JDialog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void importFromTeamSpeak() {
|
||||||
|
File db = TeamSpeakSettingsDb.locate();
|
||||||
|
if (db == null) return;
|
||||||
|
try {
|
||||||
|
TeamSpeakImporter.Result result = new TeamSpeakImporter(identities, bookmarks, settings).importIdentities(db);
|
||||||
|
reload();
|
||||||
|
JOptionPane.showMessageDialog(this, result.identities() + " identit" + (result.identities() == 1 ? "y" : "ies")
|
||||||
|
+ " read from " + db + "\n" + result.identitiesAdded() + " new, "
|
||||||
|
+ result.identitiesKnown() + " already present.",
|
||||||
|
"Import from TeamSpeak 3", JOptionPane.INFORMATION_MESSAGE);
|
||||||
|
} catch (Exception e) {
|
||||||
|
error("Could not read " + db, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void exportSelected() {
|
private void exportSelected() {
|
||||||
IdentityEntry e = list.getSelectedValue();
|
IdentityEntry e = list.getSelectedValue();
|
||||||
if (e == null) return;
|
if (e == null) return;
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ final class IdentityChooser extends JComboBox<Object> {
|
|||||||
IdentityChooser(IdentityStore identities, boolean allowDefault, String selectedId) {
|
IdentityChooser(IdentityStore identities, boolean allowDefault, String selectedId) {
|
||||||
this.identities = identities;
|
this.identities = identities;
|
||||||
this.allowDefault = allowDefault;
|
this.allowDefault = allowDefault;
|
||||||
if (allowDefault) addItem(USE_DEFAULT);
|
reload();
|
||||||
for (IdentityEntry e : identities.all()) addItem(e);
|
|
||||||
|
|
||||||
setSelectedIdentityId(selectedId);
|
setSelectedIdentityId(selectedId);
|
||||||
|
|
||||||
setRenderer(new DefaultListCellRenderer() {
|
setRenderer(new DefaultListCellRenderer() {
|
||||||
@@ -40,6 +38,15 @@ final class IdentityChooser extends JComboBox<Object> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-reads the store's identities, keeping the selection where it still exists. */
|
||||||
|
void reload() {
|
||||||
|
String selected = getSelectedIdentityId();
|
||||||
|
removeAllItems();
|
||||||
|
if (allowDefault) addItem(USE_DEFAULT);
|
||||||
|
for (IdentityEntry e : identities.all()) addItem(e);
|
||||||
|
setSelectedIdentityId(selected);
|
||||||
|
}
|
||||||
|
|
||||||
/** Selected identity id, or an empty string when the default is to be used. */
|
/** Selected identity id, or an empty string when the default is to be used. */
|
||||||
String getSelectedIdentityId() {
|
String getSelectedIdentityId() {
|
||||||
Object v = getSelectedItem();
|
Object v = getSelectedItem();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.ts3client.config.Bookmark;
|
|||||||
import com.ts3client.config.Bookmarks;
|
import com.ts3client.config.Bookmarks;
|
||||||
import com.ts3client.config.IdentityStore;
|
import com.ts3client.config.IdentityStore;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
|
import com.ts3client.teamspeak.TeamSpeakImporter;
|
||||||
import com.ts3client.contacts.ContactStore;
|
import com.ts3client.contacts.ContactStore;
|
||||||
import com.ts3client.net.ChannelNode;
|
import com.ts3client.net.ChannelNode;
|
||||||
import com.ts3client.sound.SoundNotifier;
|
import com.ts3client.sound.SoundNotifier;
|
||||||
@@ -85,6 +86,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
super("TS3J — TeamSpeak 3 Java Client");
|
super("TS3J — TeamSpeak 3 Java Client");
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.identities = IdentityStore.load(settings);
|
this.identities = IdentityStore.load(settings);
|
||||||
|
new TeamSpeakImporter(identities, bookmarks, settings).seedIfFresh();
|
||||||
this.sounds = new SoundNotifier(settings);
|
this.sounds = new SoundNotifier(settings);
|
||||||
this.soundPlayer = audio.createSoundPlayer(settings);
|
this.soundPlayer = audio.createSoundPlayer(settings);
|
||||||
this.sounds.setPlayer(soundPlayer);
|
this.sounds.setPlayer(soundPlayer);
|
||||||
@@ -235,7 +237,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onManageBookmarks() {
|
public void onManageBookmarks() {
|
||||||
new BookmarksDialog(MainFrame.this, bookmarks, identities,
|
new BookmarksDialog(MainFrame.this, bookmarks, identities, settings,
|
||||||
MainFrame.this::connectToBookmark, menuBar::rebuildBookmarks).setVisible(true);
|
MainFrame.this::connectToBookmark, menuBar::rebuildBookmarks).setVisible(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user