diff --git a/.gitignore b/.gitignore
index 05b140d..e29f3b4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ out/
# The bundled proprietary TeamSpeak 3 client (reference binary, not our source)
/TeamSpeak3-Client-linux_amd64/
+/re-android/
# Packages
*.jar
diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java b/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java
new file mode 100644
index 0000000..b03dad9
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java
@@ -0,0 +1,231 @@
+package com.ts3client.net;
+
+import com.github.manevolent.ts3j.api.Channel;
+import com.github.manevolent.ts3j.command.MultiCommand;
+import com.github.manevolent.ts3j.command.SingleCommand;
+import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
+import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent;
+import com.github.manevolent.ts3j.protocol.ProtocolRole;
+import com.ts3client.net.filetransfer.FileTransfer;
+import com.ts3client.net.filetransfer.FileTransferManager;
+import com.ts3client.net.filetransfer.RemoteFile;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.zip.CRC32;
+
+/**
+ * Everything the channel editor needs of the server: reading and writing a channel's
+ * properties and its needed-power permissions, plus the virtual server's icon store.
+ *
+ *
Split out of {@link TeamspeakConnection} because it is a self-contained request/
+ * response corner of the protocol that only the editor uses. Every call here blocks and
+ * must run off the UI thread; the connection's public wrappers do that.
+ */
+final class ChannelAdmin {
+
+ /** How long to wait for a whole icon to travel over the file connection. */
+ private static final long ICON_UPLOAD_TIMEOUT_MS = 30_000;
+
+ private final TeamspeakConnection conn;
+
+ /**
+ * The {@code channelpermlist} reply in progress. TeamSpeak answers the command with a
+ * burst of {@code notifychannelpermlist} events rather than a command response, and
+ * only the first of them repeats the channel id, so one request may be in flight at a
+ * time and its entries are collected here until the command itself completes.
+ */
+ private volatile PermRequest permRequest;
+
+ ChannelAdmin(TeamspeakConnection conn) {
+ this.conn = conn;
+ }
+
+ // ---- channel properties ----
+
+ /** Reads a channel's full property set. */
+ ChannelSettings readSettings(int channelId) throws Exception {
+ Channel info = conn.socket().getChannelInfo(channelId);
+ if (info == null) throw new IllegalStateException("The server did not describe that channel");
+ return ChannelSettings.from(channelId, info.getMap());
+ }
+
+ /** Applies {@code changes} (property name to value) with a single {@code channeledit}. */
+ void edit(int channelId, Map changes) throws Exception {
+ if (changes.isEmpty()) return;
+ SingleCommand cmd = new SingleCommand("channeledit", ProtocolRole.CLIENT);
+ cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
+ for (Map.Entry change : changes.entrySet()) {
+ cmd.add(new CommandSingleParameter(change.getKey(), change.getValue()));
+ }
+ conn.socket().executeCommand(cmd).complete();
+ }
+
+ // ---- channel permissions ----
+
+ /**
+ * Reads the permissions set directly on a channel.
+ *
+ * @return permission name to value, holding only the permissions the channel itself
+ * carries — anything inherited from a group is absent
+ */
+ Map readPermissions(int channelId) throws Exception {
+ if (!conn.getModel().hasPermissionNames()) {
+ // channelpermlist reports numeric ids, which only the permissionlist response
+ // gives names to; without it the values could not be told apart.
+ throw new IllegalStateException("The server did not send its permission list");
+ }
+ PermRequest request = new PermRequest(channelId);
+ permRequest = request;
+ try {
+ SingleCommand cmd = new SingleCommand("channelpermlist", ProtocolRole.CLIENT,
+ new CommandSingleParameter("cid", Integer.toString(channelId)));
+ conn.socket().executeCommand(cmd).complete();
+ // The entries were queued on the event thread before the command's own reply
+ // was read, so draining that thread is what guarantees they have all landed.
+ conn.awaitEventsProcessed();
+ } finally {
+ permRequest = null;
+ }
+
+ Map byName = new LinkedHashMap<>();
+ for (Map.Entry entry : request.values.entrySet()) {
+ String name = conn.getModel().permissionName(entry.getKey());
+ if (name != null) byName.put(name, entry.getValue());
+ }
+ return byName;
+ }
+
+ /** Collects one {@code notifychannelpermlist} entry for the request in flight. */
+ void onPermListEntry(UnknownTeamspeakEvent e) {
+ PermRequest request = permRequest;
+ if (request == null) return;
+ String cid = e.get("cid");
+ if (cid != null && !cid.isEmpty() && parseInt(cid, -1) != request.channelId) return;
+ int permId = parseInt(e.get("permid"), -1);
+ if (permId >= 0) request.values.put(permId, parseInt(e.get("permvalue"), 0));
+ }
+
+ /**
+ * Sets and clears channel permissions by name. A permission set to a value is added or
+ * updated; one that is cleared is removed so the channel inherits it again.
+ */
+ void writePermissions(int channelId, Map set, Collection remove)
+ throws Exception {
+ if (!set.isEmpty()) {
+ List parts = new ArrayList<>(set.size());
+ for (Map.Entry perm : set.entrySet()) {
+ parts.add(new SingleCommand("channeladdperm", ProtocolRole.CLIENT,
+ new CommandSingleParameter("permsid", perm.getKey()),
+ new CommandSingleParameter("permvalue", Integer.toString(perm.getValue()))));
+ }
+ send("channeladdperm", channelId, parts);
+ }
+ if (!remove.isEmpty()) {
+ List parts = new ArrayList<>(remove.size());
+ for (String name : remove) {
+ parts.add(new SingleCommand("channeldelperm", ProtocolRole.CLIENT,
+ new CommandSingleParameter("permsid", name)));
+ }
+ send("channeldelperm", channelId, parts);
+ }
+ }
+
+ private void send(String name, int channelId, List parts) throws Exception {
+ MultiCommand cmd = new MultiCommand(name, ProtocolRole.CLIENT, parts);
+ cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
+ conn.socket().executeCommand(cmd).complete();
+ }
+
+ // ---- the server's icon store ----
+
+ /**
+ * The ids of the icons uploaded to this virtual server, which live in channel 0's file
+ * repository as {@code /icon_}.
+ */
+ List listIcons() throws Exception {
+ List ids = new ArrayList<>();
+ for (RemoteFile file : fileTransfers().list(0, "", "/")) {
+ if (file.isDirectory() || !file.getName().startsWith("icon_")) continue;
+ long id = parseLong(file.getName().substring("icon_".length()));
+ if (id > 0) ids.add(id);
+ }
+ return ids;
+ }
+
+ /**
+ * Uploads an image as a server icon.
+ *
+ * @return the icon's id, which TeamSpeak derives from the file's CRC32
+ */
+ long uploadIcon(File source) throws Exception {
+ byte[] data = Files.readAllBytes(source.toPath());
+ if (data.length == 0) throw new IllegalArgumentException("That file is empty");
+ if (data.length > TeamspeakConnection.MAX_ICON_BYTES) {
+ throw new IllegalArgumentException("Icons may not be larger than "
+ + (TeamspeakConnection.MAX_ICON_BYTES / 1024) + " KiB");
+ }
+ CRC32 crc = new CRC32();
+ crc.update(data);
+ long id = crc.getValue();
+
+ CountDownLatch done = new CountDownLatch(1);
+ FileTransfer transfer = fileTransfers().upload(0, "", "/icon_" + id, source, true, t -> {
+ if (t.isDone()) done.countDown();
+ });
+ if (!done.await(ICON_UPLOAD_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+ throw new IllegalStateException("The upload did not finish in time");
+ }
+ if (transfer.getState() != FileTransfer.State.COMPLETED) {
+ throw new IllegalStateException(transfer.getErrorMessage() == null
+ ? "The upload failed" : transfer.getErrorMessage());
+ }
+ return id;
+ }
+
+ void deleteIcon(long iconId) throws Exception {
+ fileTransfers().delete(0, "", "/icon_" + iconId);
+ }
+
+ private FileTransferManager fileTransfers() {
+ FileTransferManager ft = conn.fileTransfers();
+ if (ft == null) throw new IllegalStateException("Not connected");
+ return ft;
+ }
+
+ // ---- helpers ----
+
+ private static int parseInt(String value, int fallback) {
+ try {
+ return value == null || value.isEmpty() ? fallback : Integer.parseInt(value.trim());
+ } catch (NumberFormatException e) {
+ return fallback;
+ }
+ }
+
+ private static long parseLong(String value) {
+ try {
+ return Long.parseLong(value.trim());
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+
+ /** One in-flight {@code channelpermlist} and the entries received for it so far. */
+ private static final class PermRequest {
+ final int channelId;
+ final Map values = new ConcurrentHashMap<>();
+
+ PermRequest(int channelId) {
+ this.channelId = channelId;
+ }
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ChannelSettings.java b/ts3-client/core/src/main/java/com/ts3client/net/ChannelSettings.java
new file mode 100644
index 0000000..0f388e8
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/net/ChannelSettings.java
@@ -0,0 +1,190 @@
+package com.ts3client.net;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * The editable properties of a channel, as read from {@code channelinfo} and written
+ * back with {@code channeledit}.
+ *
+ * Kept apart from {@link ChannelNode} on purpose: the node is the live tree state the
+ * server pushes at us, while this is a snapshot an editor works on. Submitting compares
+ * the edited copy against the untouched original ({@link #changesFrom}) so only the
+ * properties the user actually changed are sent — TeamSpeak refuses the whole command
+ * when it contains one property the client may not modify, even an unchanged one.
+ */
+public final class ChannelSettings {
+
+ /** How long a channel outlives its last occupant. */
+ public enum Type {TEMPORARY, SEMI_PERMANENT, PERMANENT}
+
+ public int id;
+ public int parentId;
+ public String name = "";
+ public String topic = "";
+ public String description = "";
+ public String phoneticName = "";
+ /** Whether the server reports a password on this channel; the password itself is never readable. */
+ public boolean hasPassword;
+ /** {@code null} leaves the password alone, {@code ""} removes it, anything else sets it. */
+ public String password;
+ public Type type = Type.PERMANENT;
+ public boolean defaultChannel;
+ /** The sibling this channel sorts after, or 0 to sort first. */
+ public int order;
+ public int neededTalkPower;
+ /** TeamSpeak codec id; see {@code com.github.manevolent.ts3j.api.Codec}. */
+ public int codec = 4;
+ public int codecQuality = 6;
+ public boolean encrypted = true;
+ public int deleteDelay;
+ public boolean maxClientsUnlimited = true;
+ public int maxClients = 16;
+ public boolean familyInherited = true;
+ public boolean familyUnlimited;
+ public int maxFamilyClients = 16;
+ public long iconId;
+
+ /** Reads a {@code channelinfo} (or {@code channellist}) property map. */
+ public static ChannelSettings from(int channelId, Map props) {
+ ChannelSettings s = new ChannelSettings();
+ s.id = channelId;
+ s.parentId = intOf(props, "pid", intOf(props, "cpid", 0));
+ s.name = strOf(props, "channel_name");
+ s.topic = strOf(props, "channel_topic");
+ s.description = strOf(props, "channel_description");
+ s.phoneticName = strOf(props, "channel_name_phonetic");
+ s.hasPassword = boolOf(props, "channel_flag_password");
+ if (boolOf(props, "channel_flag_permanent")) s.type = Type.PERMANENT;
+ else if (boolOf(props, "channel_flag_semi_permanent")) s.type = Type.SEMI_PERMANENT;
+ else s.type = Type.TEMPORARY;
+ s.defaultChannel = boolOf(props, "channel_flag_default");
+ s.order = intOf(props, "channel_order", 0);
+ s.neededTalkPower = intOf(props, "channel_needed_talk_power", 0);
+ s.codec = intOf(props, "channel_codec", 4);
+ s.codecQuality = intOf(props, "channel_codec_quality", 6);
+ s.encrypted = !boolOf(props, "channel_codec_is_unencrypted");
+ s.deleteDelay = intOf(props, "channel_delete_delay", 0);
+ s.maxClientsUnlimited = boolOf(props, "channel_flag_maxclients_unlimited");
+ s.maxClients = Math.max(0, intOf(props, "channel_maxclients", 16));
+ s.familyInherited = boolOf(props, "channel_flag_maxfamilyclients_inherited");
+ s.familyUnlimited = boolOf(props, "channel_flag_maxfamilyclients_unlimited");
+ s.maxFamilyClients = Math.max(0, intOf(props, "channel_maxfamilyclients", 16));
+ s.iconId = longOf(props, "channel_icon_id");
+ return s;
+ }
+
+ public ChannelSettings copy() {
+ ChannelSettings c = new ChannelSettings();
+ c.id = id;
+ c.parentId = parentId;
+ c.name = name;
+ c.topic = topic;
+ c.description = description;
+ c.phoneticName = phoneticName;
+ c.hasPassword = hasPassword;
+ c.password = password;
+ c.type = type;
+ c.defaultChannel = defaultChannel;
+ c.order = order;
+ c.neededTalkPower = neededTalkPower;
+ c.codec = codec;
+ c.codecQuality = codecQuality;
+ c.encrypted = encrypted;
+ c.deleteDelay = deleteDelay;
+ c.maxClientsUnlimited = maxClientsUnlimited;
+ c.maxClients = maxClients;
+ c.familyInherited = familyInherited;
+ c.familyUnlimited = familyUnlimited;
+ c.maxFamilyClients = maxFamilyClients;
+ c.iconId = iconId;
+ return c;
+ }
+
+ /**
+ * The {@code channeledit} parameters needed to turn {@code original} into this.
+ *
+ * @return property name to value, empty when nothing changed
+ */
+ public Map changesFrom(ChannelSettings original) {
+ Map changes = new LinkedHashMap<>();
+ putIfChanged(changes, "channel_name", name, original.name);
+ putIfChanged(changes, "channel_topic", topic, original.topic);
+ putIfChanged(changes, "channel_description", description, original.description);
+ putIfChanged(changes, "channel_name_phonetic", phoneticName, original.phoneticName);
+ if (password != null) changes.put("channel_password", password);
+ if (type != original.type) {
+ // The three flags are mutually exclusive and the server wants the whole set:
+ // clearing one without setting another leaves the channel type ambiguous.
+ changes.put("channel_flag_permanent", flag(type == Type.PERMANENT));
+ changes.put("channel_flag_semi_permanent", flag(type == Type.SEMI_PERMANENT));
+ changes.put("channel_flag_temporary", flag(type == Type.TEMPORARY));
+ }
+ // Clearing the default flag is meaningless — some channel has to be the default —
+ // so only setting it is ever sent.
+ if (defaultChannel && !original.defaultChannel) changes.put("channel_flag_default", "1");
+ putIfChanged(changes, "channel_order", order, original.order);
+ putIfChanged(changes, "channel_needed_talk_power", neededTalkPower, original.neededTalkPower);
+ putIfChanged(changes, "channel_codec", codec, original.codec);
+ putIfChanged(changes, "channel_codec_quality", codecQuality, original.codecQuality);
+ putIfChanged(changes, "channel_codec_is_unencrypted", !encrypted, !original.encrypted);
+ putIfChanged(changes, "channel_delete_delay", deleteDelay, original.deleteDelay);
+ putIfChanged(changes, "channel_flag_maxclients_unlimited",
+ maxClientsUnlimited, original.maxClientsUnlimited);
+ if (!maxClientsUnlimited) putIfChanged(changes, "channel_maxclients", maxClients, original.maxClients);
+ putIfChanged(changes, "channel_flag_maxfamilyclients_inherited",
+ familyInherited, original.familyInherited);
+ putIfChanged(changes, "channel_flag_maxfamilyclients_unlimited",
+ familyUnlimited, original.familyUnlimited);
+ if (!familyInherited && !familyUnlimited) {
+ putIfChanged(changes, "channel_maxfamilyclients", maxFamilyClients, original.maxFamilyClients);
+ }
+ if (iconId != original.iconId) changes.put("channel_icon_id", Long.toString(iconId));
+ return changes;
+ }
+
+ private static void putIfChanged(Map into, String key, String value, String was) {
+ if (!value.equals(was)) into.put(key, value);
+ }
+
+ private static void putIfChanged(Map into, String key, int value, int was) {
+ if (value != was) into.put(key, Integer.toString(value));
+ }
+
+ private static void putIfChanged(Map into, String key, boolean value, boolean was) {
+ if (value != was) into.put(key, flag(value));
+ }
+
+ private static String flag(boolean set) {
+ return set ? "1" : "0";
+ }
+
+ private static String strOf(Map props, String key) {
+ String v = props.get(key);
+ return v == null ? "" : v;
+ }
+
+ private static boolean boolOf(Map props, String key) {
+ return "1".equals(props.get(key));
+ }
+
+ private static int intOf(Map props, String key, int fallback) {
+ try {
+ String v = props.get(key);
+ return v == null || v.isEmpty() ? fallback : Integer.parseInt(v.trim());
+ } catch (NumberFormatException e) {
+ return fallback;
+ }
+ }
+
+ private static long longOf(Map props, String key) {
+ try {
+ String v = props.get(key);
+ if (v == null || v.isEmpty()) return 0;
+ long id = Long.parseLong(v.trim());
+ return id < 0 ? id & 0xFFFFFFFFL : id;
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java
index bfec351..db5a301 100644
--- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java
+++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java
@@ -562,6 +562,7 @@ final class ConnectionEventHandler implements TS3Listener {
@Override
public void onUnknownEvent(UnknownTeamspeakEvent e) {
if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e);
+ else if ("notifychannelpermlist".equals(e.getCommand())) conn.channels.onPermListEntry(e);
}
// ---- helpers ----
diff --git a/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java b/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java
index 286fd1c..a64c65f 100644
--- a/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java
+++ b/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java
@@ -6,6 +6,7 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
+import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -29,6 +30,12 @@ public final class IconRepository {
/** Icons at or below this id are bundled defaults rather than server uploads. */
public static final int MAX_BUNDLED_ID = 1000;
+ /**
+ * The default group icons shipped with the client, in the order TeamSpeak's own icon
+ * packs list them. These are what the icon chooser offers as "local" icons.
+ */
+ public static final List BUNDLED_IDS = List.of(100L, 200L, 300L, 500L, 600L);
+
/** Downloads {@code /icon_} from the connected server's file repository. */
public interface Fetcher {
byte[] fetchIcon(long iconId) throws Exception;
diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java
index 1832325..c31d52a 100644
--- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java
+++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java
@@ -132,6 +132,16 @@ public final class ServerModel {
* yet (the {@code permissionlist} request is still in flight, or the server
* never reported a non-default value for it).
*/
+ /** Whether the server's {@code permissionlist} has arrived, so ids can be named at all. */
+ public synchronized boolean hasPermissionNames() {
+ return !permissionNames.isEmpty();
+ }
+
+ /** The name the server gave permission id {@code id}, or {@code null} if unknown. */
+ public synchronized String permissionName(int id) {
+ return permissionNames.get(id);
+ }
+
public synchronized int selfPermissionValue(String name) {
for (Map.Entry entry : permissionNames.entrySet()) {
if (entry.getValue().equals(name)) {
@@ -201,6 +211,24 @@ public final class ServerModel {
c.order = newOrder;
}
+ /**
+ * The channels sharing a parent with {@code channelId}, in the order they appear in the
+ * tree and excluding the channel itself — what the editor offers as places to sort after.
+ */
+ public synchronized List siblingsOf(int channelId) {
+ ChannelNode channel = channels.get(channelId);
+ if (channel == null) return new ArrayList<>();
+ List siblings = new ArrayList<>();
+ for (ChannelNode c : channels.values()) {
+ if (c.parentId == channel.parentId) siblings.add(c);
+ }
+ // Ordered with the channel still in place: the order links form a chain, and
+ // pulling a link out of it first would strand everything below.
+ sortSiblings(siblings);
+ siblings.remove(channel);
+ return siblings;
+ }
+
/** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */
public synchronized String channelPath(int id) {
ChannelNode c = channels.get(id);
diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java
index 62fe48f..55d8e60 100644
--- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java
+++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java
@@ -27,6 +27,10 @@ import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiConsumer;
import java.util.function.Consumer;
/**
@@ -36,8 +40,8 @@ import java.util.function.Consumer;
*/
public final class TeamspeakConnection implements TS3Listener {
- /** Upper bound for a downloaded group icon; anything larger is not an icon. */
- private static final int MAX_ICON_BYTES = 1024 * 1024;
+ /** Upper bound for a group icon; anything larger is not an icon. Also used by {@link ChannelAdmin}. */
+ static final int MAX_ICON_BYTES = 1024 * 1024;
/** The protocol's reason ids for the two flavours of clientkick. */
private static final int REASON_KICK_CHANNEL = 4;
@@ -58,6 +62,8 @@ public final class TeamspeakConnection implements TS3Listener {
private final ConnectionEventHandler events = new ConnectionEventHandler(this);
/** Builds {@code getconnectioninfo} snapshots; also package-private for {@link ConnectionEventHandler}. */
final ConnectionStatsCollector stats = new ConnectionStatsCollector(this);
+ /** Channel editing and the server's icon store; package-private for {@link ConnectionEventHandler}. */
+ final ChannelAdmin channels = new ChannelAdmin(this);
/** Package-private: read directly by {@link ConnectionStatsCollector}. */
LocalTeamspeakClientSocket client;
@@ -65,6 +71,12 @@ public final class TeamspeakConnection implements TS3Listener {
private VoiceOutput playback;
private LocalIdentity identity;
private FileTransferManager fileTransfers;
+ /**
+ * Runs ts3j's event callbacks. Supplying it ourselves (rather than leaving ts3j to make
+ * its own) is what lets {@link #awaitEventsProcessed()} tell when everything the server
+ * has already sent has been handled. Closed together with the socket.
+ */
+ private ExecutorService eventExecutor;
private volatile boolean connected;
private volatile int selfClientId = -1;
@@ -231,6 +243,12 @@ public final class TeamspeakConnection implements TS3Listener {
microphone.setMutedTalkListener(this::onTalkingWhileMuted);
client = new LocalTeamspeakClientSocket();
+ eventExecutor = Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r, "ts3j-events");
+ t.setDaemon(true);
+ return t;
+ });
+ client.setCommandExecutorService(eventExecutor);
client.setIdentity(identity);
client.setNickname(nickname);
client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode()));
@@ -487,6 +505,8 @@ public final class TeamspeakConnection implements TS3Listener {
VoiceOutput out = playback;
LocalTeamspeakClientSocket sock = client;
FileTransferManager ft = fileTransfers;
+ ExecutorService events = eventExecutor;
+ eventExecutor = null;
microphone = null;
playback = null;
client = null;
@@ -519,6 +539,7 @@ public final class TeamspeakConnection implements TS3Listener {
} catch (Exception ignored) {
}
}
+ if (events != null) events.shutdownNow();
}
// ---- self actions ----
@@ -866,6 +887,111 @@ public final class TeamspeakConnection implements TS3Listener {
}, "ts3j-clientinfo").start();
}
+ // ---- channel administration ----
+
+ /**
+ * Reads a channel's editable properties, delivering them (or a failure message) to
+ * {@code callback} off the UI thread.
+ */
+ public void requestChannelSettings(int channelId, BiConsumer callback) {
+ run("ts3j-channel-read", callback, () -> channels.readSettings(channelId));
+ }
+
+ /**
+ * Reads the permissions set directly on a channel, as name/value pairs. Fails when the
+ * server does not let us look at them.
+ */
+ public void requestChannelPermissions(int channelId,
+ BiConsumer