Add the Edit Channel dialog
Recovers the official client's CreateChannelDialog layout so the widget set, labels and tab order match: name/icon/password/topic/description above Standard, Audio, Permissions and Advanced tabs. Core gains ChannelSettings (diffed against the original so channeledit only carries changed properties) and ChannelAdmin (channelinfo/edit, channel permissions, the server icon store). TeamspeakConnection now owns the event executor so it can drain queued notify events before reading a command's result, which channelpermlist needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
*
|
||||
* <p>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<String, String> 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<String, String> 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<String, Integer> 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<String, Integer> byName = new LinkedHashMap<>();
|
||||
for (Map.Entry<Integer, Integer> 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<String, Integer> set, Collection<String> remove)
|
||||
throws Exception {
|
||||
if (!set.isEmpty()) {
|
||||
List<SingleCommand> parts = new ArrayList<>(set.size());
|
||||
for (Map.Entry<String, Integer> 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<SingleCommand> 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<SingleCommand> 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_<id>}.
|
||||
*/
|
||||
List<Long> listIcons() throws Exception {
|
||||
List<Long> 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<Integer, Integer> values = new ConcurrentHashMap<>();
|
||||
|
||||
PermRequest(int channelId) {
|
||||
this.channelId = channelId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}.
|
||||
*
|
||||
* <p>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<String, String> 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<String, String> changesFrom(ChannelSettings original) {
|
||||
Map<String, String> 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<String, String> into, String key, String value, String was) {
|
||||
if (!value.equals(was)) into.put(key, value);
|
||||
}
|
||||
|
||||
private static void putIfChanged(Map<String, String> into, String key, int value, int was) {
|
||||
if (value != was) into.put(key, Integer.toString(value));
|
||||
}
|
||||
|
||||
private static void putIfChanged(Map<String, String> 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<String, String> props, String key) {
|
||||
String v = props.get(key);
|
||||
return v == null ? "" : v;
|
||||
}
|
||||
|
||||
private static boolean boolOf(Map<String, String> props, String key) {
|
||||
return "1".equals(props.get(key));
|
||||
}
|
||||
|
||||
private static int intOf(Map<String, String> 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<String, String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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<Long> BUNDLED_IDS = List.of(100L, 200L, 300L, 500L, 600L);
|
||||
|
||||
/** Downloads {@code /icon_<id>} from the connected server's file repository. */
|
||||
public interface Fetcher {
|
||||
byte[] fetchIcon(long iconId) throws Exception;
|
||||
|
||||
@@ -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<Integer, String> 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<ChannelNode> siblingsOf(int channelId) {
|
||||
ChannelNode channel = channels.get(channelId);
|
||||
if (channel == null) return new ArrayList<>();
|
||||
List<ChannelNode> 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);
|
||||
|
||||
@@ -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 <b>clientkick</b>. */
|
||||
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<ChannelSettings, String> 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<Map<String, Integer>, String> callback) {
|
||||
run("ts3j-channel-perms", callback, () -> channels.readPermissions(channelId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an edited channel: its changed properties first, then its needed-power
|
||||
* permissions.
|
||||
*
|
||||
* @param callback given {@code null} on success, or the failure message
|
||||
*/
|
||||
public void applyChannelEdit(int channelId, Map<String, String> changes,
|
||||
Map<String, Integer> setPermissions,
|
||||
Collection<String> removePermissions,
|
||||
Consumer<String> callback) {
|
||||
run("ts3j-channel-edit", (ignored, error) -> callback.accept(error), () -> {
|
||||
channels.edit(channelId, changes);
|
||||
channels.writePermissions(channelId, setPermissions, removePermissions);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/** The ids of the icons uploaded to this virtual server. */
|
||||
public void requestServerIcons(BiConsumer<List<Long>, String> callback) {
|
||||
run("ts3j-icon-list", callback, channels::listIcons);
|
||||
}
|
||||
|
||||
/** Uploads an image as a server icon and reports the new icon's id. */
|
||||
public void uploadIcon(File source, BiConsumer<Long, String> callback) {
|
||||
run("ts3j-icon-upload", callback, () -> channels.uploadIcon(source));
|
||||
}
|
||||
|
||||
/** Deletes a server icon; {@code callback} is given {@code null} on success. */
|
||||
public void deleteIcon(long iconId, Consumer<String> callback) {
|
||||
run("ts3j-icon-delete", (ignored, error) -> callback.accept(error), () -> {
|
||||
channels.deleteIcon(iconId);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/** Work that produces a value or an error message, run on its own thread. */
|
||||
private interface Job<T> {
|
||||
T call() throws Exception;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a blocking server request in the background and hands the result — or the
|
||||
* failure message — to {@code callback}, which therefore never runs on the UI thread.
|
||||
*/
|
||||
private <T> void run(String threadName, BiConsumer<T, String> callback, Job<T> job) {
|
||||
new Thread(() -> {
|
||||
if (!connected || client == null) {
|
||||
callback.accept(null, "Not connected");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
callback.accept(job.call(), null);
|
||||
} catch (Exception e) {
|
||||
callback.accept(null, rootMessage(e));
|
||||
}
|
||||
}, threadName).start();
|
||||
}
|
||||
|
||||
/** The live socket. Package-private for {@link ChannelAdmin}. */
|
||||
LocalTeamspeakClientSocket socket() {
|
||||
LocalTeamspeakClientSocket sock = client;
|
||||
if (sock == null || !connected) throw new IllegalStateException("Not connected");
|
||||
return sock;
|
||||
}
|
||||
|
||||
/** Package-private for {@link ChannelAdmin}. */
|
||||
FileTransferManager fileTransfers() {
|
||||
return fileTransfers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks until ts3j has finished dispatching every event it had already queued, so a
|
||||
* caller that just saw a command complete can be sure the notifications the server sent
|
||||
* alongside it have been handled. Must not be called from the event thread itself.
|
||||
*/
|
||||
void awaitEventsProcessed() {
|
||||
ExecutorService events = eventExecutor;
|
||||
if (events == null) return;
|
||||
try {
|
||||
events.submit(() -> {
|
||||
}).get(5, TimeUnit.SECONDS);
|
||||
} catch (Exception ignored) {
|
||||
// A shutting-down or wedged event thread just means the caller gets what arrived.
|
||||
}
|
||||
}
|
||||
|
||||
// ---- icons ----
|
||||
|
||||
/** Group icons for this server: bundled defaults plus the server's own uploads. */
|
||||
|
||||
Reference in New Issue
Block a user