Run a connection's requests on its own pool

Each action started a thread that read the socket field once it ran; a
disconnect in between cleared it, and the user saw "Could not join
channel: NullPointerException". Actions now capture the socket when
they are issued and run on a pool that lives as long as the connection.
What fails because the connection went away is no longer reported as an
error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 10:29:23 +00:00
parent ba5de5a8a8
commit f350b19d31

View File

@@ -40,6 +40,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
@@ -102,6 +103,8 @@ public final class TeamspeakConnection implements TS3Listener {
* has already sent has been handled. Closed together with the socket. * has already sent has been handled. Closed together with the socket.
*/ */
private ExecutorService eventExecutor; private ExecutorService eventExecutor;
/** Runs this connection's requests to the server; created on connect, shut down with the socket. */
private volatile ExecutorService actions;
private volatile boolean connected; private volatile boolean connected;
/** /**
@@ -211,7 +214,6 @@ public final class TeamspeakConnection implements TS3Listener {
private void pushInputHardware() { private void pushInputHardware() {
boolean hardware = microphoneActive; boolean hardware = microphoneActive;
updateSelf(self -> self.inputHardware = hardware); updateSelf(self -> self.inputHardware = hardware);
if (client == null || !connected) return;
selfUpdate(cmd -> selfUpdate(cmd ->
cmd.add(new CommandSingleParameter("client_input_hardware", hardware ? "1" : "0")), cmd.add(new CommandSingleParameter("client_input_hardware", hardware ? "1" : "0")),
"Microphone hardware status update failed"); "Microphone hardware status update failed");
@@ -283,6 +285,11 @@ public final class TeamspeakConnection implements TS3Listener {
return t; return t;
}); });
client.setCommandExecutorService(eventExecutor); client.setCommandExecutorService(eventExecutor);
actions = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "ts3j-action");
t.setDaemon(true);
return t;
});
client.setIdentity(identity); client.setIdentity(identity);
client.setNickname(nickname); client.setNickname(nickname);
client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode())); client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode()));
@@ -520,7 +527,9 @@ public final class TeamspeakConnection implements TS3Listener {
LocalTeamspeakClientSocket sock = client; LocalTeamspeakClientSocket sock = client;
FileTransferManager ft = fileTransfers; FileTransferManager ft = fileTransfers;
ExecutorService events = eventExecutor; ExecutorService events = eventExecutor;
ExecutorService pool = actions;
eventExecutor = null; eventExecutor = null;
actions = null;
microphone = null; microphone = null;
playback = null; playback = null;
client = null; client = null;
@@ -555,6 +564,7 @@ public final class TeamspeakConnection implements TS3Listener {
} }
} }
if (events != null) events.shutdownNow(); if (events != null) events.shutdownNow();
if (pool != null) pool.shutdownNow();
} }
// ---- self actions ---- // ---- self actions ----
@@ -594,7 +604,6 @@ public final class TeamspeakConnection implements TS3Listener {
// Publish the input/output muted flags to the server so others see them. These // Publish the input/output muted flags to the server so others see them. These
// are runtime status, not editable client-database properties, so they go out // are runtime status, not editable client-database properties, so they go out
// through clientupdate (like nickname/away), not clientedit. // through clientupdate (like nickname/away), not clientedit.
if (client == null || !connected) return;
selfUpdate(cmd -> { selfUpdate(cmd -> {
cmd.add(new CommandSingleParameter("client_input_muted", micMuted ? "1" : "0")); cmd.add(new CommandSingleParameter("client_input_muted", micMuted ? "1" : "0"));
cmd.add(new CommandSingleParameter("client_output_muted", deaf ? "1" : "0")); cmd.add(new CommandSingleParameter("client_output_muted", deaf ? "1" : "0"));
@@ -602,25 +611,14 @@ public final class TeamspeakConnection implements TS3Listener {
} }
public void joinChannel(int channelId, String password) { public void joinChannel(int channelId, String password) {
new Thread(() -> { act("Could not join channel: ", sock ->
try { sock.joinChannel(channelId, (password == null || password.isEmpty()) ? null : password));
client.joinChannel(channelId, (password == null || password.isEmpty()) ? null : password);
} catch (Exception e) {
error("Could not join channel: " + rootMessage(e));
}
}, "ts3j-join").start();
} }
/** Moves another client (or ourselves) into a channel. */ /** Moves another client (or ourselves) into a channel. */
public void moveClient(int clientId, int channelId, String password) { public void moveClient(int clientId, int channelId, String password) {
new Thread(() -> { act("Could not move client: ", sock -> sock.clientMove(clientId, channelId,
try { (password == null || password.isEmpty()) ? null : password));
client.clientMove(clientId, channelId,
(password == null || password.isEmpty()) ? null : password);
} catch (Exception e) {
error("Could not move client: " + rootMessage(e));
}
}, "ts3j-move-client").start();
} }
/** Kicks a client out of its channel, back into the server's default one. */ /** Kicks a client out of its channel, back into the server's default one. */
@@ -634,19 +632,15 @@ public final class TeamspeakConnection implements TS3Listener {
} }
private void kick(int clientId, int reasonId, String reason, String errorPrefix) { private void kick(int clientId, int reasonId, String reason, String errorPrefix) {
new Thread(() -> { act(errorPrefix, sock -> {
try {
SingleCommand cmd = new SingleCommand("clientkick", ProtocolRole.CLIENT); SingleCommand cmd = new SingleCommand("clientkick", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("clid", Integer.toString(clientId))); cmd.add(new CommandSingleParameter("clid", Integer.toString(clientId)));
cmd.add(new CommandSingleParameter("reasonid", Integer.toString(reasonId))); cmd.add(new CommandSingleParameter("reasonid", Integer.toString(reasonId)));
if (reason != null && !reason.isEmpty()) { if (reason != null && !reason.isEmpty()) {
cmd.add(new CommandSingleParameter("reasonmsg", reason)); cmd.add(new CommandSingleParameter("reasonmsg", reason));
} }
client.executeCommand(cmd).complete(); sock.executeCommand(cmd).complete();
} catch (Exception e) { });
error(errorPrefix + rootMessage(e));
}
}, "ts3j-kick").start();
} }
/** /**
@@ -655,14 +649,9 @@ public final class TeamspeakConnection implements TS3Listener {
* @param seconds how long the ban lasts, or 0 for a permanent one * @param seconds how long the ban lasts, or 0 for a permanent one
*/ */
public void banClient(int clientId, long seconds, String reason) { public void banClient(int clientId, long seconds, String reason) {
new Thread(() -> { act("Could not ban client: ", sock -> sock.banClient(clientId,
try { seconds <= 0 ? null : (int) Math.min(seconds, Integer.MAX_VALUE),
client.banClient(clientId, seconds <= 0 ? null : (int) Math.min(seconds, Integer.MAX_VALUE), reason == null || reason.isEmpty() ? null : reason));
reason == null || reason.isEmpty() ? null : reason);
} catch (Exception e) {
error("Could not ban client: " + rootMessage(e));
}
}, "ts3j-ban").start();
} }
/** /**
@@ -676,8 +665,7 @@ public final class TeamspeakConnection implements TS3Listener {
// The server refuses channelmove within the same parent ("already member of // The server refuses channelmove within the same parent ("already member of
// channel"); sorting among the same siblings is an edit of channel_order. // channel"); sorting among the same siblings is an edit of channel_order.
boolean reorder = channel != null && channel.parentId == newParentId; boolean reorder = channel != null && channel.parentId == newParentId;
new Thread(() -> { act("Could not move channel: ", sock -> {
try {
SingleCommand cmd; SingleCommand cmd;
if (reorder) { if (reorder) {
cmd = new SingleCommand("channeledit", ProtocolRole.CLIENT); cmd = new SingleCommand("channeledit", ProtocolRole.CLIENT);
@@ -689,11 +677,8 @@ public final class TeamspeakConnection implements TS3Listener {
cmd.add(new CommandSingleParameter("cpid", Integer.toString(newParentId))); cmd.add(new CommandSingleParameter("cpid", Integer.toString(newParentId)));
cmd.add(new CommandSingleParameter("order", Integer.toString(orderPredecessorId))); cmd.add(new CommandSingleParameter("order", Integer.toString(orderPredecessorId)));
} }
client.executeCommand(cmd).complete(); sock.executeCommand(cmd).complete();
} catch (Exception e) { });
error("Could not move channel: " + rootMessage(e));
}
}, "ts3j-move-channel").start();
} }
/** /**
@@ -705,40 +690,27 @@ public final class TeamspeakConnection implements TS3Listener {
* won't be known yet. * won't be known yet.
*/ */
private void requestPermissionNames() { private void requestPermissionNames() {
new Thread(() -> {
try {
client.executeCommand(new SingleCommand("permissionlist", ProtocolRole.CLIENT)).complete();
} catch (Exception ignored) {
// Best-effort: the menus fall back to treating unresolved permissions as 0. // Best-effort: the menus fall back to treating unresolved permissions as 0.
} attempt(sock -> sock.executeCommand(new SingleCommand("permissionlist", ProtocolRole.CLIENT)).complete());
}, "ts3j-permission-list").start();
} }
/** Assigns or removes a server group for a client (by database id, as {@code servergroupaddclient} needs). */ /** Assigns or removes a server group for a client (by database id, as {@code servergroupaddclient} needs). */
public void setClientServerGroup(int clientDatabaseId, int groupId, boolean assign) { public void setClientServerGroup(int clientDatabaseId, int groupId, boolean assign) {
new Thread(() -> { act("Could not " + (assign ? "assign" : "remove") + " server group: ", sock -> {
try { if (assign) sock.serverGroupAddClient(groupId, clientDatabaseId);
if (assign) client.serverGroupAddClient(groupId, clientDatabaseId); else sock.serverGroupRemoveClient(groupId, clientDatabaseId);
else client.serverGroupRemoveClient(groupId, clientDatabaseId); });
} catch (Exception e) {
error("Could not " + (assign ? "assign" : "remove") + " server group: " + rootMessage(e));
}
}, "ts3j-server-group").start();
} }
/** Assigns a channel group for a client in the channel it currently sits in. */ /** Assigns a channel group for a client in the channel it currently sits in. */
public void setClientChannelGroup(int clientDatabaseId, int channelId, int groupId) { public void setClientChannelGroup(int clientDatabaseId, int channelId, int groupId) {
new Thread(() -> { act("Could not set channel group: ", sock -> {
try {
SingleCommand cmd = new SingleCommand("setclientchannelgroup", ProtocolRole.CLIENT); SingleCommand cmd = new SingleCommand("setclientchannelgroup", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("cgid", Integer.toString(groupId))); cmd.add(new CommandSingleParameter("cgid", Integer.toString(groupId)));
cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId))); cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
cmd.add(new CommandSingleParameter("cldbid", Integer.toString(clientDatabaseId))); cmd.add(new CommandSingleParameter("cldbid", Integer.toString(clientDatabaseId)));
client.executeCommand(cmd).complete(); sock.executeCommand(cmd).complete();
} catch (Exception e) { });
error("Could not set channel group: " + rootMessage(e));
}
}, "ts3j-channel-group").start();
} }
/** /**
@@ -749,76 +721,56 @@ public final class TeamspeakConnection implements TS3Listener {
if (channelIds.isEmpty()) return; if (channelIds.isEmpty()) return;
List<Integer> ids = List.copyOf(channelIds); List<Integer> ids = List.copyOf(channelIds);
String name = subscribed ? "channelsubscribe" : "channelunsubscribe"; String name = subscribed ? "channelsubscribe" : "channelunsubscribe";
new Thread(() -> { act(subscribed ? "Could not subscribe: " : "Could not unsubscribe: ", sock -> {
try {
List<SingleCommand> parts = new ArrayList<>(ids.size()); List<SingleCommand> parts = new ArrayList<>(ids.size());
for (int id : ids) { for (int id : ids) {
parts.add(new SingleCommand(name, ProtocolRole.CLIENT, parts.add(new SingleCommand(name, ProtocolRole.CLIENT,
new CommandSingleParameter("cid", Integer.toString(id)))); new CommandSingleParameter("cid", Integer.toString(id))));
} }
client.executeCommand(new MultiCommand(name, ProtocolRole.CLIENT, parts)).complete(); sock.executeCommand(new MultiCommand(name, ProtocolRole.CLIENT, parts)).complete();
} catch (Exception e) { });
error((subscribed ? "Could not subscribe: " : "Could not unsubscribe: ") + rootMessage(e));
}
}, "ts3j-channel-subscribe").start();
} }
public void sendChannelMessage(String text) { public void sendChannelMessage(String text) {
new Thread(() -> { act("Message failed: ", sock -> {
try {
ClientEntry self = model.getClient(selfClientId); ClientEntry self = model.getClient(selfClientId);
int cid = self != null ? self.channelId : 0; int cid = self != null ? self.channelId : 0;
client.sendChannelMessage(cid, text); sock.sendChannelMessage(cid, text);
persistOutgoingChat(ConnectionListener.ChatScope.CHANNEL, 0, "", text); persistOutgoingChat(ConnectionListener.ChatScope.CHANNEL, 0, "", text);
sound(SoundEvent.CHAT_SENT_MESSAGE_CHANNEL, channelVars(cid, null)); sound(SoundEvent.CHAT_SENT_MESSAGE_CHANNEL, channelVars(cid, null));
} catch (Exception e) { });
error("Message failed: " + rootMessage(e));
}
}, "ts3j-chan-msg").start();
} }
public void sendServerMessage(String text) { public void sendServerMessage(String text) {
new Thread(() -> { act("Message failed: ", sock -> {
try { sock.sendServerMessage(text);
client.sendServerMessage(text);
persistOutgoingChat(ConnectionListener.ChatScope.SERVER, 0, "", text); persistOutgoingChat(ConnectionListener.ChatScope.SERVER, 0, "", text);
sound(SoundEvent.CHAT_SENT_MESSAGE_SERVER, serverVars()); sound(SoundEvent.CHAT_SENT_MESSAGE_SERVER, serverVars());
} catch (Exception e) { });
error("Message failed: " + rootMessage(e));
}
}, "ts3j-srv-msg").start();
} }
public void sendPrivateMessage(int clientId, String text) { public void sendPrivateMessage(int clientId, String text) {
new Thread(() -> { act("Message failed: ", sock -> {
try { sock.sendPrivateMessage(clientId, text);
client.sendPrivateMessage(clientId, text);
ClientEntry peer = model.getClient(clientId); ClientEntry peer = model.getClient(clientId);
String peerUid = peer != null ? peer.uniqueId : ""; String peerUid = peer != null ? peer.uniqueId : "";
if (peerUid == null || peerUid.isBlank()) { if (peerUid == null || peerUid.isBlank()) {
try { try {
Client info = client.getClientInfo(clientId); Client info = sock.getClientInfo(clientId);
if (info != null) peerUid = info.getUniqueIdentifier(); if (info != null) peerUid = info.getUniqueIdentifier();
} catch (Exception ignored) { } catch (Exception ignored) {
} }
} }
persistOutgoingChat(ConnectionListener.ChatScope.PRIVATE, clientId, peerUid, text); persistOutgoingChat(ConnectionListener.ChatScope.PRIVATE, clientId, peerUid, text);
sound(SoundEvent.CHAT_SENT_MESSAGE_CLIENT, clientVars(clientId, null)); sound(SoundEvent.CHAT_SENT_MESSAGE_CLIENT, clientVars(clientId, null));
} catch (Exception e) { });
error("Message failed: " + rootMessage(e));
}
}, "ts3j-pm").start();
} }
public void poke(int clientId, String message) { public void poke(int clientId, String message) {
new Thread(() -> { act("Poke failed: ", sock -> {
try { sock.clientPoke(clientId, message);
client.clientPoke(clientId, message);
sound(SoundEvent.OTHER_SENT_POKE, clientVars(clientId, null)); sound(SoundEvent.OTHER_SENT_POKE, clientVars(clientId, null));
} catch (Exception e) { });
error("Poke failed: " + rootMessage(e));
}
}, "ts3j-poke").start();
} }
public void setAway(boolean away, String message) { public void setAway(boolean away, String message) {
@@ -852,17 +804,13 @@ public final class TeamspeakConnection implements TS3Listener {
ui.onModelChanged(); ui.onModelChanged();
} }
/** Sends a {@code clientupdate} for the local client on a background thread. */ /** Sends a {@code clientupdate} for the local client in the background. */
private void selfUpdate(java.util.function.Consumer<SingleCommand> fill, String errorLabel) { private void selfUpdate(java.util.function.Consumer<SingleCommand> fill, String errorLabel) {
new Thread(() -> { act(errorLabel + ": ", sock -> {
try {
SingleCommand cmd = new SingleCommand("clientupdate", ProtocolRole.CLIENT); SingleCommand cmd = new SingleCommand("clientupdate", ProtocolRole.CLIENT);
fill.accept(cmd); fill.accept(cmd);
client.executeCommand(cmd).complete(); sock.executeCommand(cmd).complete();
} catch (Exception e) { });
error(errorLabel + ": " + rootMessage(e));
}
}, "ts3j-selfupdate").start();
} }
public double getPingMillis() { public double getPingMillis() {
@@ -874,13 +822,7 @@ public final class TeamspeakConnection implements TS3Listener {
} }
public void setNickname(String nickname) { public void setNickname(String nickname) {
new Thread(() -> { act("Rename failed: ", sock -> sock.setNickname(nickname));
try {
client.setNickname(nickname);
} catch (Exception e) {
error("Rename failed: " + rootMessage(e));
}
}, "ts3j-rename").start();
} }
/* /*
@@ -894,7 +836,7 @@ public final class TeamspeakConnection implements TS3Listener {
* {@link ConnectionEventHandler#onChannelEdit}. * {@link ConnectionEventHandler#onChannelEdit}.
*/ */
public void requestChannelInfo(int channelId) { public void requestChannelInfo(int channelId) {
requestInfo("channelgetdescription", "cid", channelId, "ts3j-channelinfo"); requestInfo("channelgetdescription", "cid", channelId);
} }
/** /**
@@ -902,18 +844,13 @@ public final class TeamspeakConnection implements TS3Listener {
* lacks. The answer arrives as a client update, see {@link ConnectionEventHandler#onClientChanged}. * lacks. The answer arrives as a client update, see {@link ConnectionEventHandler#onClientChanged}.
*/ */
public void requestClientInfo(int clientId) { public void requestClientInfo(int clientId) {
requestInfo("clientgetvariables", "clid", clientId, "ts3j-clientinfo"); requestInfo("clientgetvariables", "clid", clientId);
} }
private void requestInfo(String command, String idParameter, int id, String threadName) { /** May be refused for lack of permission; the details then stay as they are. */
new Thread(() -> { private void requestInfo(String command, String idParameter, int id) {
try { attempt(sock -> sock.executeCommand(new SingleCommand(command, ProtocolRole.CLIENT,
client.executeCommand(new SingleCommand(command, ProtocolRole.CLIENT, new CommandSingleParameter(idParameter, Integer.toString(id)))).complete());
new CommandSingleParameter(idParameter, Integer.toString(id)))).complete();
} catch (Exception ignored) {
// may be permission-restricted; leave as-is
}
}, threadName).start();
} }
/** /**
@@ -921,8 +858,8 @@ public final class TeamspeakConnection implements TS3Listener {
* live text instead of whatever an earlier info request cached. * live text instead of whatever an earlier info request cached.
*/ */
public void requestClientDescription(int clientId, BiConsumer<String, String> callback) { public void requestClientDescription(int clientId, BiConsumer<String, String> callback) {
run("ts3j-client-description", callback, () -> { run(callback, () -> {
Client c = client.getClientInfo(clientId); Client c = socket().getClientInfo(clientId);
String description = c == null ? "" : orEmpty(c.get("client_description")); String description = c == null ? "" : orEmpty(c.get("client_description"));
ClientEntry e = model.getClient(clientId); ClientEntry e = model.getClient(clientId);
if (e != null) e.description = description; if (e != null) e.description = description;
@@ -939,11 +876,11 @@ public final class TeamspeakConnection implements TS3Listener {
*/ */
public void setClientDescription(int clientId, String description, Consumer<String> callback) { public void setClientDescription(int clientId, String description, Consumer<String> callback) {
boolean self = clientId == selfClientId; boolean self = clientId == selfClientId;
run("ts3j-client-description-set", (ignored, error) -> callback.accept(error), () -> { run((ignored, error) -> callback.accept(error), () -> {
SingleCommand cmd = new SingleCommand("clientedit", ProtocolRole.CLIENT); SingleCommand cmd = new SingleCommand("clientedit", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("clid", Integer.toString(clientId))); cmd.add(new CommandSingleParameter("clid", Integer.toString(clientId)));
cmd.add(new CommandSingleParameter("client_description", description)); cmd.add(new CommandSingleParameter("client_description", description));
client.executeCommand(cmd).complete(); socket().executeCommand(cmd).complete();
// Only the other clients are told about our own update, so the local entry // Only the other clients are told about our own update, so the local entry
// (and the info panel showing it) has to be caught up here. // (and the info panel showing it) has to be caught up here.
if (self) { if (self) {
@@ -959,12 +896,12 @@ public final class TeamspeakConnection implements TS3Listener {
/** Reads the server's ban list, delivering it (or a failure message) off the UI thread. */ /** Reads the server's ban list, delivering it (or a failure message) off the UI thread. */
public void requestBanList(BiConsumer<List<BanEntry>, String> callback) { public void requestBanList(BiConsumer<List<BanEntry>, String> callback) {
run("ts3j-ban-list", callback, bans::list); run(callback, bans::list);
} }
/** @param callback given {@code null} on success, or the failure message */ /** @param callback given {@code null} on success, or the failure message */
public void addBan(BanRule rule, Consumer<String> callback) { public void addBan(BanRule rule, Consumer<String> callback) {
run("ts3j-ban-add", (ignored, error) -> callback.accept(error), () -> { run((ignored, error) -> callback.accept(error), () -> {
bans.add(rule); bans.add(rule);
return null; return null;
}); });
@@ -976,7 +913,7 @@ public final class TeamspeakConnection implements TS3Listener {
* @param callback given {@code null} on success, or the failure message * @param callback given {@code null} on success, or the failure message
*/ */
public void replaceBan(int banId, BanRule rule, Consumer<String> callback) { public void replaceBan(int banId, BanRule rule, Consumer<String> callback) {
run("ts3j-ban-edit", (ignored, error) -> callback.accept(error), () -> { run((ignored, error) -> callback.accept(error), () -> {
bans.replace(banId, rule); bans.replace(banId, rule);
return null; return null;
}); });
@@ -988,7 +925,7 @@ public final class TeamspeakConnection implements TS3Listener {
* @param callback given {@code null} on success, or the failure message * @param callback given {@code null} on success, or the failure message
*/ */
public void deleteBans(Collection<Integer> banIds, Consumer<String> callback) { public void deleteBans(Collection<Integer> banIds, Consumer<String> callback) {
run("ts3j-ban-delete", (ignored, error) -> callback.accept(error), () -> { run((ignored, error) -> callback.accept(error), () -> {
for (int id : banIds) bans.delete(id); for (int id : banIds) bans.delete(id);
return null; return null;
}); });
@@ -1082,7 +1019,7 @@ public final class TeamspeakConnection implements TS3Listener {
* {@code callback} off the UI thread. * {@code callback} off the UI thread.
*/ */
public void requestChannelSettings(int channelId, BiConsumer<ChannelSettings, String> callback) { public void requestChannelSettings(int channelId, BiConsumer<ChannelSettings, String> callback) {
run("ts3j-channel-read", callback, () -> channels.readSettings(channelId)); run(callback, () -> channels.readSettings(channelId));
} }
/** /**
@@ -1091,7 +1028,7 @@ public final class TeamspeakConnection implements TS3Listener {
*/ */
public void requestChannelPermissions(int channelId, public void requestChannelPermissions(int channelId,
BiConsumer<Map<String, Integer>, String> callback) { BiConsumer<Map<String, Integer>, String> callback) {
run("ts3j-channel-perms", callback, () -> channels.readPermissions(channelId)); run(callback, () -> channels.readPermissions(channelId));
} }
/** /**
@@ -1104,7 +1041,7 @@ public final class TeamspeakConnection implements TS3Listener {
Map<String, Integer> setPermissions, Map<String, Integer> setPermissions,
Collection<String> removePermissions, Collection<String> removePermissions,
Consumer<String> callback) { Consumer<String> callback) {
run("ts3j-channel-edit", (ignored, error) -> callback.accept(error), () -> { run((ignored, error) -> callback.accept(error), () -> {
channels.edit(channelId, changes); channels.edit(channelId, changes);
channels.writePermissions(channelId, setPermissions, removePermissions); channels.writePermissions(channelId, setPermissions, removePermissions);
if (changes.containsKey("channel_description")) { if (changes.containsKey("channel_description")) {
@@ -1130,7 +1067,7 @@ public final class TeamspeakConnection implements TS3Listener {
public void createChannel(int parentId, Map<String, String> properties, public void createChannel(int parentId, Map<String, String> properties,
Map<String, Integer> permissions, Map<String, Integer> permissions,
BiConsumer<Integer, String> callback) { BiConsumer<Integer, String> callback) {
run("ts3j-channel-create", callback, () -> { run(callback, () -> {
int channelId = channels.create(parentId, properties); int channelId = channels.create(parentId, properties);
try { try {
channels.writePermissions(channelId, permissions, List.of()); channels.writePermissions(channelId, permissions, List.of());
@@ -1144,43 +1081,82 @@ public final class TeamspeakConnection implements TS3Listener {
/** The ids of the icons uploaded to this virtual server. */ /** The ids of the icons uploaded to this virtual server. */
public void requestServerIcons(BiConsumer<List<Long>, String> callback) { public void requestServerIcons(BiConsumer<List<Long>, String> callback) {
run("ts3j-icon-list", callback, channels::listIcons); run(callback, channels::listIcons);
} }
/** Uploads an image as a server icon and reports the new icon's id. */ /** Uploads an image as a server icon and reports the new icon's id. */
public void uploadIcon(File source, BiConsumer<Long, String> callback) { public void uploadIcon(File source, BiConsumer<Long, String> callback) {
run("ts3j-icon-upload", callback, () -> channels.uploadIcon(source)); run(callback, () -> channels.uploadIcon(source));
} }
/** Deletes a server icon; {@code callback} is given {@code null} on success. */ /** Deletes a server icon; {@code callback} is given {@code null} on success. */
public void deleteIcon(long iconId, Consumer<String> callback) { public void deleteIcon(long iconId, Consumer<String> callback) {
run("ts3j-icon-delete", (ignored, error) -> callback.accept(error), () -> { run((ignored, error) -> callback.accept(error), () -> {
channels.deleteIcon(iconId); channels.deleteIcon(iconId);
return null; return null;
}); });
} }
/** Work that produces a value or an error message, run on its own thread. */ /** A request made on the socket of the connection it was issued on. */
private interface Action {
void run(LocalTeamspeakClientSocket sock) throws Exception;
}
/** Work that produces a value or an error message. */
private interface Job<T> { private interface Job<T> {
T call() throws Exception; T call() throws Exception;
} }
/** Sends a request in the background, reporting a failure as {@code errorPrefix} and the reason. */
private void act(String errorPrefix, Action action) {
submit(action, e -> error(errorPrefix + rootMessage(e)));
}
/** Sends a request in the background whose failure nobody needs to hear about. */
private void attempt(Action action) {
submit(action, e -> {
});
}
private void submit(Action action, Consumer<Exception> onFailure) {
LocalTeamspeakClientSocket sock = client;
ExecutorService pool = actions;
if (sock == null || pool == null || !connected) return;
try {
pool.execute(() -> {
try {
action.run(sock);
} catch (Exception e) {
// Leaving fails whatever was still in flight; that is no error to report.
if (client == sock && connected) onFailure.accept(e);
}
});
} catch (RejectedExecutionException ignored) {
// disconnected in the meantime
}
}
/** /**
* Runs a blocking server request in the background and hands the result — or the * 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. * 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) { private <T> void run(BiConsumer<T, String> callback, Job<T> job) {
new Thread(() -> { ExecutorService pool = actions;
if (!connected || client == null) { if (pool == null || client == null || !connected) {
callback.accept(null, "Not connected"); callback.accept(null, "Not connected");
return; return;
} }
try {
pool.execute(() -> {
try { try {
callback.accept(job.call(), null); callback.accept(job.call(), null);
} catch (Exception e) { } catch (Exception e) {
callback.accept(null, rootMessage(e)); callback.accept(null, connected ? rootMessage(e) : "Not connected");
}
});
} catch (RejectedExecutionException e) {
callback.accept(null, "Not connected");
} }
}, threadName).start();
} }
/** The live socket. Package-private for {@link ChannelAdmin}. */ /** The live socket. Package-private for {@link ChannelAdmin}. */
@@ -1248,7 +1224,7 @@ public final class TeamspeakConnection implements TS3Listener {
* @param callback given {@code null} on success, or the failure message * @param callback given {@code null} on success, or the failure message
*/ */
public void setAvatar(File source, Consumer<String> callback) { public void setAvatar(File source, Consumer<String> callback) {
run("ts3j-avatar-upload", (flag, error) -> { run((flag, error) -> {
if (error == null) updateSelf(self -> self.avatarFlag = flag); if (error == null) updateSelf(self -> self.avatarFlag = flag);
callback.accept(error); callback.accept(error);
}, () -> avatarAdmin.upload(source)); }, () -> avatarAdmin.upload(source));
@@ -1256,7 +1232,7 @@ public final class TeamspeakConnection implements TS3Listener {
/** Withdraws our avatar; {@code callback} is given {@code null} on success. */ /** Withdraws our avatar; {@code callback} is given {@code null} on success. */
public void removeAvatar(Consumer<String> callback) { public void removeAvatar(Consumer<String> callback) {
run("ts3j-avatar-remove", (ignored, error) -> { run((ignored, error) -> {
if (error == null) updateSelf(self -> self.avatarFlag = ""); if (error == null) updateSelf(self -> self.avatarFlag = "");
callback.accept(error); callback.accept(error);
}, () -> { }, () -> {