From b69484538156a83794c2f67627dd2149a8d49107 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Tue, 18 Aug 2026 13:37:43 +0000 Subject: [PATCH] Split TeamspeakConnection into focused collaborator classes Extract notify-event handling and the native-style server log/sound announcements into ConnectionEventHandler, and getconnectioninfo request/response + stats-snapshot building into ConnectionStatsCollector. TeamspeakConnection remains the public facade (1828 -> 1037 lines); its external API is unchanged. Pure refactor, no protocol/timing changes. Co-Authored-By: Claude Sonnet 5 --- .../ts3client/net/ConnectionEventHandler.java | 590 ++++++++++++ .../net/ConnectionStatsCollector.java | 300 ++++++ .../ts3client/net/TeamspeakConnection.java | 865 +----------------- .../com/ts3client/net/EventFieldsTest.java | 2 +- 4 files changed, 928 insertions(+), 829 deletions(-) create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/ConnectionStatsCollector.java 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 new file mode 100644 index 0000000..8c91930 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java @@ -0,0 +1,590 @@ +package com.ts3client.net; + +import com.github.manevolent.ts3j.event.*; +import com.ts3client.sound.SoundEvent; +import com.ts3client.sound.SoundNotifier; + +import java.util.Map; + +/** + * The {@link TS3Listener} side of a connection: keeps {@link ServerModel} in sync with + * incoming notify events, mirrors native TeamSpeak's server-tab log lines, and picks the + * matching {@link SoundEvent} for each one. Registered alongside {@link TeamspeakConnection} + * itself (which keeps only the lifecycle-critical {@code onDisconnected} callback) so this + * class can stay focused on "event arrived, update state and tell the user" and nothing else. + */ +final class ConnectionEventHandler implements TS3Listener { + + // ---- who went where: TeamSpeak's reason ids ---- + + /** {@code reasonid} of a client view/move notification. */ + private static final int REASON_SWITCHED = 0; + private static final int REASON_MOVED = 1; + private static final int REASON_TIMEOUT = 3; + private static final int REASON_CHANNEL_KICK = 4; + private static final int REASON_SERVER_KICK = 5; + private static final int REASON_BAN = 6; + + private final TeamspeakConnection conn; + + ConnectionEventHandler(TeamspeakConnection conn) { + this.conn = conn; + } + + @Override + public void onClientJoin(ClientJoinEvent e) { + ClientEntry c = conn.getModel().putClient(e.getClientId(), e.getClientNickname(), e.getClientTargetId()); + c.type = safeInt(e, "client_type"); + c.talkPower = e.getClientTalkPower(); + c.inputMuted = e.isClientInputMuted(); + c.outputMuted = e.isClientOutputMuted(); + c.inputHardware = e.isClientUsingHardwareInput(); + c.outputHardware = e.isClientUsingHardwareOutput(); + c.away = e.isClientAway(); + c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message")); + c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier()); + c.serverGroupIds = parseIntList(e.getClientServerGroups()); + c.channelGroupId = e.getClientChannelGroupId(); + c.self = (e.getClientId() == conn.getSelfClientId()); + if (e.getClientId() != conn.getSelfClientId()) { + announceClientEntered(e); + logClientEntered(e); + } + conn.ui.onModelChanged(); + } + + @Override + public void onClientLeave(ClientLeaveEvent e) { + if (e.getClientId() == conn.getSelfClientId()) { + announceOwnRemoval(safeInt(e, "reasonid"), e); + } else { + ClientEntry leaving = conn.getModel().getClient(e.getClientId()); + String name = leaving != null ? leaving.nickname : "Client " + e.getClientId(); + announceClientLeft(e); + logClientLeft(e, name); + } + conn.getModel().removeClient(e.getClientId()); + if (conn.getPlayback() != null) conn.getPlayback().removeClient(e.getClientId()); + conn.ui.onModelChanged(); + } + + @Override + public void onClientMoved(ClientMovedEvent e) { + ClientEntry c = conn.getModel().getClient(e.getClientId()); + if (c != null) { + int from = c.channelId; + c.channelId = e.getTargetChannelId(); + if (e.getClientId() == conn.getSelfClientId()) { + announceOwnMove(safeInt(e, "reasonid"), e); + } else { + announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId()); + logClientMoved(e, c.nickname, from, e.getTargetChannelId()); + } + conn.ui.onModelChanged(); + } + } + + /** A client became visible to us, logged the way native TS3's server tab does. */ + private void logClientEntered(ClientJoinEvent e) { + String name = e.getClientNickname(); + switch (safeInt(e, "reasonid")) { + case REASON_MOVED: + conn.log(name + " appears, coming from channel \"" + conn.channelName(e.getClientFromId()) + "\""); + break; + case REASON_CHANNEL_KICK: + conn.log(name + " appears, was kicked from channel \"" + conn.channelName(e.getClientFromId()) + + "\" by " + invokerName(e)); + break; + case REASON_SWITCHED: + conn.log(name + " switched to channel \"" + conn.channelName(e.getClientTargetId()) + + "\", coming from channel \"" + conn.channelName(e.getClientFromId()) + "\""); + break; + default: + conn.log(name + " connected to channel \"" + conn.channelName(e.getClientTargetId()) + "\""); + } + } + + /** A client stopped being visible to us. */ + private void logClientLeft(ClientLeaveEvent e, String name) { + String reasonMsg = TeamspeakConnection.orEmpty(e.get("reasonmsg")); + String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")"; + switch (safeInt(e, "reasonid")) { + case REASON_TIMEOUT: + conn.log(name + " dropped (ping timeout)"); + break; + case REASON_SERVER_KICK: + conn.log(name + " was kicked from the server by " + invokerName(e) + suffix); + break; + case REASON_BAN: + conn.log(name + " was banned from the server by " + invokerName(e) + suffix); + break; + case REASON_CHANNEL_KICK: + conn.log(name + " left: was kicked to channel \"" + conn.channelName(e.getClientTargetId()) + + "\" by " + invokerName(e) + suffix); + break; + case REASON_MOVED: + conn.log(name + " left, heading to channel \"" + conn.channelName(e.getClientTargetId()) + "\""); + break; + case REASON_SWITCHED: + conn.log(name + " left, switched to channel \"" + conn.channelName(e.getClientTargetId()) + "\""); + break; + default: + conn.log(name + " disconnected"); + } + } + + /** A client we can see moved between two channels we can see. */ + private void logClientMoved(ClientMovedEvent e, String name, int fromChannel, int toChannel) { + String reasonMsg = TeamspeakConnection.orEmpty(e.get("reasonmsg")); + String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")"; + switch (safeInt(e, "reasonid")) { + case REASON_MOVED: + conn.log(name + " was moved from channel \"" + conn.channelName(fromChannel) + "\" to \"" + + conn.channelName(toChannel) + "\" by " + invokerName(e)); + break; + case REASON_CHANNEL_KICK: + conn.log(name + " was kicked from channel \"" + conn.channelName(fromChannel) + "\" to \"" + + conn.channelName(toChannel) + "\" by " + invokerName(e) + suffix); + break; + default: + conn.log(name + " switched from channel \"" + conn.channelName(fromChannel) + "\" to \"" + + conn.channelName(toChannel) + "\""); + } + } + + /** + * A client became visible: either they just connected, or they moved in from a + * channel we could not see — TeamSpeak's "appears" case. + */ + private void announceClientEntered(ClientJoinEvent e) { + if (!conn.isConnected()) return; + int clientId = e.getClientId(); + boolean current = conn.inOwnChannel(e.getClientTargetId()); + Map vars = conn.clientVars(clientId, e.getClientNickname()); + switch (safeInt(e, "reasonid")) { + case REASON_MOVED: + conn.sound(current ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_APPEARS + : SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_APPEARS, vars); + break; + case REASON_CHANNEL_KICK: + conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_APPEARS + : SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_APPEARS, vars); + break; + case REASON_SWITCHED: + conn.sound(current ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS + : SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_APPEARS, vars); + break; + default: + conn.sound(current ? SoundEvent.CLIENT_CONNECTION_CONNECTED_CURRENT_CHANNEL + : SoundEvent.CLIENT_CONNECTION_CONNECTED_SERVER, vars); + } + } + + /** A client stopped being visible: they left the server, or moved out of sight. */ + private void announceClientLeft(ClientLeaveEvent e) { + if (!conn.isConnected()) return; + int clientId = e.getClientId(); + boolean current = conn.inOwnChannel(e.getClientFromId()); + Map vars = conn.clientVars(clientId, null); + switch (safeInt(e, "reasonid")) { + case REASON_TIMEOUT: + conn.sound(current ? SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_CURRENT_CHANNEL + : SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_SERVER, vars); + break; + case REASON_SERVER_KICK: + conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_CURRENT_CHANNEL + : SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_SERVER, vars); + break; + case REASON_BAN: + conn.sound(current ? SoundEvent.CLIENT_WAS_BANNED_CURRENT_CHANNEL + : SoundEvent.CLIENT_WAS_BANNED_SERVER, vars); + break; + case REASON_CHANNEL_KICK: + conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_DISAPPEARS + : SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_DISAPPEARS, vars); + break; + case REASON_MOVED: + conn.sound(current ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_DISAPPEARS + : SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_DISAPPEARS, vars); + break; + case REASON_SWITCHED: + conn.sound(current ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_DISAPPEARS + : SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_DISAPPEARS, vars); + break; + default: + conn.sound(current ? SoundEvent.CLIENT_CONNECTION_DISCONNECTED_CURRENT_CHANNEL + : SoundEvent.CLIENT_CONNECTION_DISCONNECTED_SERVER, vars); + } + } + + /** A client we can see moved between two channels we can see ("stays"). */ + private void announceClientMoved(int reason, int clientId, int fromChannel, int toChannel) { + if (!conn.isConnected()) return; + boolean toCurrent = conn.inOwnChannel(toChannel); + boolean fromCurrent = conn.inOwnChannel(fromChannel); + Map vars = conn.clientVars(clientId, null); + switch (reason) { + case REASON_MOVED: + conn.sound(toCurrent ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS + : fromCurrent ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_STAYS + : SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_STAYS, vars); + break; + case REASON_CHANNEL_KICK: + conn.sound(toCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_STAYS + : fromCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_STAYS + : SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_STAYS, vars); + break; + default: + conn.sound(toCurrent ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_STAYS + : fromCurrent ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_STAYS + : SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_STAYS, vars); + } + } + + /** We changed channel ourselves, or somebody changed it for us. */ + private void announceOwnMove(int reason, ClientMovedEvent e) { + if (!conn.isConnected()) return; + Map vars = conn.channelVars(e.getTargetChannelId(), e.get("invokername")); + switch (reason) { + case REASON_MOVED: + conn.sound(SoundEvent.YOU_WERE_MOVED_TO_DIFFERENT_CHANNEL, vars); + break; + case REASON_CHANNEL_KICK: + conn.sound(SoundEvent.YOU_WERE_KICKED_FROM_CHANNEL, vars); + break; + default: + conn.sound(SoundEvent.YOU_SWITCHED_CHANNEL, vars); + } + } + + /** We were removed from the server (kick or ban); the disconnect follows. */ + private void announceOwnRemoval(int reason, ClientLeaveEvent e) { + if (!conn.isConnected()) return; + Map vars = SoundNotifier.vars( + "servername", conn.getModel().getServerName(), + "clientname", TeamspeakConnection.orEmpty(e.get("invokername")), + "reason", TeamspeakConnection.orEmpty(e.get("reasonmsg"))); + if (reason == REASON_SERVER_KICK) { + conn.sound(SoundEvent.YOU_WERE_KICKED_FROM_SERVER, vars); + } else if (reason == REASON_BAN) { + conn.sound(SoundEvent.YOU_WERE_BANNED, vars); + } + } + + @Override + public void onClientChanged(ClientUpdatedEvent e) { + ClientEntry c = conn.getModel().getClient(e.getClientId()); + if (c == null) return; + boolean renamed = has(e, "client_nickname") && !e.get("client_nickname").equals(c.nickname); + String oldName = c.nickname; + if (renamed) c.nickname = e.get("client_nickname"); + if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted"); + if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted"); + if (has(e, "client_input_hardware")) c.inputHardware = e.getBoolean("client_input_hardware"); + if (has(e, "client_output_hardware")) c.outputHardware = e.getBoolean("client_output_hardware"); + if (has(e, "client_away")) { + c.away = e.getBoolean("client_away"); + // Both fields travel together, so an absent message here means "no message" + // — which `has` cannot tell from "not reported". + c.awayMessage = c.away ? TeamspeakConnection.orEmpty(e.get("client_away_message")) : ""; + } else if (has(e, "client_away_message")) { + c.awayMessage = e.get("client_away_message"); + } + if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power"); + if (has(e, "client_is_channel_commander")) + c.channelCommander = e.getBoolean("client_is_channel_commander"); + announceClientUpdate(e, c, renamed, oldName); + conn.ui.onModelChanged(); + } + + /** Renames, talk-power changes and recording flags all arrive as a client update. */ + private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed, String oldName) { + if (!conn.isConnected()) return; + boolean self = c.id == conn.getSelfClientId(); + Map vars = conn.clientVars(c.id, null); + + if (renamed) { + conn.sound(safeInt(e, "invokerid") == conn.getSelfClientId() + ? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars); + conn.log(oldName + " is now known as " + c.nickname); + } + if (safeInt(e, "client_talk_request") > 0 && !self) { + conn.sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars); + } + if (self && has(e, "client_is_talker")) { + conn.sound(e.getBoolean("client_is_talker") + ? SoundEvent.YOU_WERE_GRANTED_TALK_POWER : SoundEvent.YOU_WERE_REVOKED_TALK_POWER, vars); + } + if (!self && has(e, "client_is_recording")) { + boolean recording = e.getBoolean("client_is_recording"); + if (!recording) { + conn.sound(SoundEvent.CLIENT_RECORDING_STOP, vars); + } else { + conn.sound(conn.inOwnChannel(c.channelId) + ? SoundEvent.CLIENT_RECORDING_IN_CHANNEL : SoundEvent.CLIENT_RECORDING_START, vars); + } + } + } + + @Override + public void onChannelCreate(ChannelCreateEvent e) { + int cid = e.getChannelId(); + String name = e.get("channel_name"); + int pid = safeInt(e, "cpid"); + if (pid == 0) pid = safeInt(e, "pid"); + int order = safeInt(e, "channel_order"); + ChannelNode node = conn.getModel().putChannel(cid, name, pid, order); + long icon = TeamspeakConnection.safeLong(e, "channel_icon_id"); + if (icon != 0) node.iconId = icon; + conn.getModel().relinkChannel(cid, pid, order); + if (conn.isConnected()) { + conn.sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER, + SoundEvent.CHANNEL_CREATED_BY_OTHER), conn.channelVars(cid, e.get("invokername"))); + conn.log("Channel \"" + name + "\" was created by " + invokerName(e)); + } + conn.ui.onModelChanged(); + } + + @Override + public void onChannelDeleted(ChannelDeletedEvent e) { + int cid = e.getChannelId(); + if (conn.isConnected()) { + conn.sound(byInvoker(e, SoundEvent.CHANNEL_DELETED_BY_YOU, SoundEvent.CHANNEL_DELETED_BY_OTHER, + SoundEvent.CHANNEL_DELETED_BY_SERVER), conn.channelVars(cid, e.get("invokername"))); + conn.log("Channel \"" + conn.channelName(cid) + "\" was deleted by " + invokerName(e)); + } + conn.getModel().removeChannel(cid); + conn.ui.onModelChanged(); + } + + @Override + public void onChannelEdit(ChannelEditedEvent e) { + ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid")); + if (ch != null) { + if (has(e, "channel_name")) ch.name = e.get("channel_name"); + if (has(e, "channel_order")) ch.order = e.getInt("channel_order"); + if (has(e, "channel_icon_id")) ch.iconId = TeamspeakConnection.safeLong(e, "channel_icon_id"); + if (conn.isConnected()) { + boolean current = conn.inOwnChannel(ch.id); + conn.sound(current + ? byInvoker(e, SoundEvent.CHANNEL_EDITED_CURRENT_BY_YOU, + SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER, SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER) + : byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU, + SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER), + conn.channelVars(ch.id, e.get("invokername"))); + conn.log("Channel \"" + ch.name + "\" was edited by " + invokerName(e)); + } + conn.ui.onModelChanged(); + } + } + + @Override + public void onChannelMoved(ChannelMovedEvent e) { + ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid")); + if (ch != null) { + int parent = has(e, "cpid") ? e.getInt("cpid") : ch.parentId; + int order = has(e, "order") ? e.getInt("order") : ch.order; + conn.getModel().relinkChannel(ch.id, parent, order); + if (conn.isConnected()) { + conn.sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER, + SoundEvent.CHANNEL_MOVED_BY_OTHER), conn.channelVars(ch.id, e.get("invokername"))); + conn.log("Channel \"" + ch.name + "\" was moved by " + invokerName(e)); + } + conn.ui.onModelChanged(); + } + } + + @Override + public void onChannelSubscribed(ChannelSubscribedEvent e) { + setSubscribed(safeInt(e, "cid"), true); + } + + @Override + public void onChannelUnsubscribed(ChannelUnsubscribedEvent e) { + setSubscribed(safeInt(e, "cid"), false); + } + + private void setSubscribed(int cid, boolean subscribed) { + ChannelNode ch = conn.getModel().getChannel(cid); + if (ch == null || ch.subscribed == subscribed) return; + ch.subscribed = subscribed; + conn.ui.onModelChanged(); + } + + @Override + public void onServerEdit(ServerEditedEvent e) { + if (has(e, "virtualserver_name")) conn.getModel().setServerName(e.get("virtualserver_name")); + if (conn.isConnected()) { + conn.sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER, + SoundEvent.SERVER_EDITED_BY_OTHER), conn.serverVars()); + } + conn.ui.onModelChanged(); + } + + @Override + public void onServerGroupClientAdded(ServerGroupClientAddedEvent e) { + boolean self = e.getClientId() == conn.getSelfClientId(); + conn.sound(self + ? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, + SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER) + : byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, + SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_SERVER), + groupVars(e.getClientId(), e.getName())); + conn.log(clientLogName(e.getClientId()) + " was added to server group \"" + e.getName() + + "\" by " + invokerName(e) + "."); + } + + @Override + public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) { + int clientId = safeInt(e, "clid"); + boolean self = clientId == conn.getSelfClientId(); + conn.sound(self + ? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, + SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER) + : byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, + SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_SERVER), + groupVars(clientId, e.get("name"))); + conn.log(clientLogName(clientId) + " was removed from server group \"" + TeamspeakConnection.orEmpty(e.get("name")) + + "\" by " + invokerName(e) + "."); + } + + @Override + public void onClientChannelGroupChanged(ClientChannelGroupChangedEvent e) { + ClientEntry c = conn.getModel().getClient(e.getClientId()); + if (c != null) c.channelGroupId = e.getChannelGroupId(); + boolean self = e.getClientId() == conn.getSelfClientId(); + String groupName = conn.getModel().channelGroupName(e.getChannelGroupId()); + conn.sound(self + ? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, + SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_SERVER) + : byInvoker(e, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, + SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_SERVER), + groupVars(e.getClientId(), groupName)); + conn.log("Channel group \"" + TeamspeakConnection.orEmpty(groupName) + "\" was assigned to " + + clientLogName(e.getClientId()) + " by " + invokerName(e) + "."); + conn.ui.onModelChanged(); + } + + /** A client's nickname for a log line, falling back to its id once it has left. */ + private String clientLogName(int clientId) { + ClientEntry c = conn.getModel().getClient(clientId); + return c != null ? c.nickname : "Client " + clientId; + } + + /** Picks the event variant matching who caused the change: us, another client, or the server. */ + private SoundEvent byInvoker(BaseEvent e, SoundEvent byYou, SoundEvent byOther, SoundEvent byServer) { + int invoker = safeInt(e, "invokerid"); + if (invoker == conn.getSelfClientId() && invoker != 0) return byYou; + return invoker == 0 ? byServer : byOther; + } + + private Map groupVars(int clientId, String groupName) { + Map vars = conn.clientVars(clientId, null); + vars.put("groupname", TeamspeakConnection.orEmpty(groupName)); + return vars; + } + + @Override + public void onChannelList(ChannelListEvent e) { + // Incremental channel arriving during connect. + int cid = e.getChannelId(); + String name = e.get("channel_name"); + int pid = safeInt(e, "cpid"); + int order = safeInt(e, "channel_order"); + ChannelNode node = conn.getModel().putChannel(cid, name, pid, order); + long icon = TeamspeakConnection.safeLong(e, "channel_icon_id"); + if (icon != 0) node.iconId = icon; + } + + @Override + public void onServerGroupList(ServerGroupListEvent e) { + int id = safeInt(e, "sgid"); + if (id > 0) conn.getModel().putServerGroup(toGroup(e, id)); + } + + @Override + public void onChannelGroupList(ChannelGroupListEvent e) { + int id = safeInt(e, "cgid"); + if (id > 0) conn.getModel().putChannelGroup(toGroup(e, id)); + } + + private static Group toGroup(BaseEvent e, int id) { + return new Group(id, e.get("name"), TeamspeakConnection.safeLong(e, "iconid"), safeInt(e, "sortid")); + } + + @Override + public void onTextMessage(TextMessageEvent e) { + if (e.getInvokerId() == conn.getSelfClientId()) return; // don't echo our own + ConnectionListener.ChatScope scope; + SoundEvent notification; + switch (e.getTargetMode()) { + case CLIENT: + scope = ConnectionListener.ChatScope.PRIVATE; + notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CLIENT; + break; + case CHANNEL: + scope = ConnectionListener.ChatScope.CHANNEL; + notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL; + break; + default: + scope = ConnectionListener.ChatScope.SERVER; + notification = SoundEvent.CHAT_RECEIVED_MESSAGE_SERVER; + break; + } + conn.sound(notification, conn.clientVars(e.getInvokerId(), e.getInvokerName())); + conn.ui.onChat(scope, e.getInvokerId(), e.getInvokerName(), e.getMessage()); + } + + @Override + public void onClientPoke(ClientPokeEvent e) { + conn.sound(SoundEvent.OTHER_RECEIVED_POKE, conn.clientVars(e.getInvokerId(), e.getInvokerName())); + conn.ui.onPoke(TeamspeakConnection.orEmpty(e.getInvokerName()), TeamspeakConnection.orEmpty(e.get("msg"))); + } + + @Override + public void onUnknownEvent(UnknownTeamspeakEvent e) { + if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e); + } + + // ---- helpers ---- + + private static String invokerName(BaseEvent e) { + String name = TeamspeakConnection.orEmpty(e.get("invokername")); + return name.isEmpty() ? "the server" : name; + } + + private static int safeInt(BaseEvent e, String key) { + try { + String v = e.get(key); + return v == null ? 0 : Integer.parseInt(v.trim()); + } catch (Exception ex) { + return 0; + } + } + + /** + * Whether the event actually carries a field. ts3j answers a missing key with an + * empty string rather than null, so a plain null check is always true — and a + * partial update (say, someone muting) would otherwise look like it reported + * every other field as well. + */ + private static boolean has(BaseEvent e, String key) { + String value = e.get(key); + return value != null && !value.isEmpty(); + } + + /** Parses a comma-separated id list (e.g. server groups "6,12,15"). */ + private static int[] parseIntList(String csv) { + if (csv == null || csv.isEmpty()) return new int[0]; + String[] parts = csv.split(","); + int[] out = new int[parts.length]; + int n = 0; + for (String p : parts) { + try { + out[n++] = Integer.parseInt(p.trim()); + } catch (NumberFormatException ignored) { + } + } + return n == parts.length ? out : java.util.Arrays.copyOf(out, n); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionStatsCollector.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionStatsCollector.java new file mode 100644 index 0000000..cf6265c --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionStatsCollector.java @@ -0,0 +1,300 @@ +package com.ts3client.net; + +import com.github.manevolent.ts3j.api.Client; +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.PacketKind; +import com.github.manevolent.ts3j.protocol.ProtocolRole; +import com.github.manevolent.ts3j.protocol.packet.statistics.PacketStatistics; +import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket; +import com.github.manevolent.ts3j.util.Pair; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * Builds {@link ConnectionStats} snapshots for the info dialog. The local client's own + * figures are read straight from its live packet counters; a remote client's come from + * {@code getconnectioninfo}, whose {@code notifyconnectioninfo} report arrives + * asynchronously via {@link #onReport} and is matched back up by client id. + */ +final class ConnectionStatsCollector { + + private final TeamspeakConnection conn; + + /** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */ + private final Map pending = new ConcurrentHashMap<>(); + + ConnectionStatsCollector(TeamspeakConnection conn) { + this.conn = conn; + } + + /** Drops any requests left over from a connection that just went away. */ + void clear() { + pending.clear(); + } + + /** + * Fetches connection statistics for a client and delivers them to + * {@code callback} (invoked off the Swing EDT — the callback is responsible + * for marshalling). May be called repeatedly to poll. + */ + void request(int clientId, Consumer callback) { + if (clientId == conn.getSelfClientId()) { + new Thread(() -> callback.accept(buildLocalStats()), "ts3j-conninfo-self").start(); + } else { + new Thread(() -> requestRemote(clientId, callback), "ts3j-conninfo").start(); + } + } + + /** Matches an asynchronously arriving {@code notifyconnectioninfo} report to its request. */ + void onReport(UnknownTeamspeakEvent e) { + int clid = safeInt(e.get("clid")); + PendingConnInfo p = pending.remove(clid); + if (p == null) return; + applyConnectionFields(p.stats, e.getMap()); + p.callback.accept(p.stats); + } + + /** Builds a live snapshot of the local client's connection from its own counters. */ + private ConnectionStats buildLocalStats() { + ConnectionStats s = new ConnectionStats(); + s.clientId = conn.getSelfClientId(); + s.self = true; + s.live = true; + s.packetLoss = 0; // the local client has no server->client loss figure + + ClientEntry self = conn.getModel().getClient(conn.getSelfClientId()); + if (self != null) { + s.nickname = self.nickname; + s.version = self.version; + s.platform = self.platform; + s.idleTimeMs = self.idleTimeMs; + } + long connectedAt = conn.connectedAtMs; + if (connectedAt > 0) s.connectedTimeMs = System.currentTimeMillis() - connectedAt; + + LocalTeamspeakClientSocket c = conn.client; + if (c == null) return s; + + try { + Pair ping = c.getPing(); + s.pingMs = ping.getKey() * 1000.0; + s.pingDeviationMs = ping.getValue() * 1000.0; + } catch (Exception ignored) { + // ping unavailable; leave as unknown + } + + long pSent = 0, pRecv = 0, bSent = 0, bRecv = 0; + long bwSs = 0, bwRs = 0, bwSm = 0, bwRm = 0; + for (PacketKind kind : PacketKind.values()) { + PacketStatistics st = c.getStatistics(kind); + ConnectionStats.KindStats ks = new ConnectionStats.KindStats(mapKind(kind)); + ks.packetLoss = 0; // the local client cannot measure its own server->client loss + ks.packetsSent = st.getSentPackets(); + ks.packetsReceived = st.getReceivedPackets(); + ks.bytesSent = st.getSentBytes(); + ks.bytesReceived = st.getReceivedBytes(); + ks.bandwidthSentLastSecond = st.getSentBytesLastSecond(); + ks.bandwidthReceivedLastSecond = st.getReceivedBytesLastSecond(); + ks.bandwidthSentLastMinute = st.getSentBytesLastMinute(); + ks.bandwidthReceivedLastMinute = st.getReceivedBytesLastMinute(); + s.perKind.add(ks); + + pSent += ks.packetsSent; + pRecv += ks.packetsReceived; + bSent += ks.bytesSent; + bRecv += ks.bytesReceived; + bwSs += ks.bandwidthSentLastSecond; + bwRs += ks.bandwidthReceivedLastSecond; + bwSm += ks.bandwidthSentLastMinute; + bwRm += ks.bandwidthReceivedLastMinute; + } + s.packetsSentTotal = pSent; + s.packetsReceivedTotal = pRecv; + s.bytesSentTotal = bSent; + s.bytesReceivedTotal = bRecv; + s.bandwidthSentLastSecond = bwSs; + s.bandwidthReceivedLastSecond = bwRs; + s.bandwidthSentLastMinute = bwSm; + s.bandwidthReceivedLastMinute = bwRm; + return s; + } + + /** + * Requests a remote client's connection info. First loads {@code clientinfo} + * for the stable fields, then issues {@code getconnectioninfo} whose + * {@code notifyconnectioninfo} report arrives asynchronously via + * {@link #onReport}. If the report does not arrive shortly (e.g. the + * server withholds it), the clientinfo-only snapshot is delivered instead. + */ + private void requestRemote(int clientId, Consumer callback) { + ConnectionStats s = new ConnectionStats(); + s.clientId = clientId; + ClientEntry entry = conn.getModel().getClient(clientId); + if (entry != null) s.nickname = entry.nickname; + + LocalTeamspeakClientSocket client = conn.client; + try { + Client c = client.getClientInfo(clientId); + if (c != null) { + s.version = TeamspeakConnection.orEmpty(c.getVersion()); + s.platform = TeamspeakConnection.orEmpty(c.getPlatform()); + s.ip = TeamspeakConnection.orEmpty(c.getIp()); + s.idleTimeMs = c.getIdleTime(); + if (entry == null) s.nickname = TeamspeakConnection.orEmpty(c.getNickname()); + applyConnectionFields(s, c.getMap()); + } + } catch (Exception ignored) { + // clientinfo may be permission-restricted; continue with what we have + } + + PendingConnInfo p = new PendingConnInfo(callback, s); + pending.put(clientId, p); + + boolean sent = false; + try { + SingleCommand cmd = new SingleCommand("getconnectioninfo", ProtocolRole.CLIENT, + new CommandSingleParameter("clid", Integer.toString(clientId))); + client.executeCommand(cmd).complete(); + sent = true; + } catch (Exception ignored) { + // command failed; fall back to the clientinfo snapshot below + } + + if (sent) { + try { + Thread.sleep(700); // give notifyconnectioninfo a chance to arrive + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + // If the report already arrived, onReport removed and delivered it. + if (pending.remove(clientId, p)) { + callback.accept(s); + } + } + + /** Parses TeamSpeak {@code connection_*} fields from a command map into {@code s}. */ + private static void applyConnectionFields(ConnectionStats s, Map m) { + double ping = parseDouble(m.get("connection_ping")); + if (ping >= 0) s.pingMs = ping; + double dev = parseDouble(m.get("connection_ping_deviation")); + if (dev >= 0) s.pingDeviationMs = dev; + double loss = parseDouble(m.get("connection_packetloss_total")); + if (loss < 0) loss = parseDouble(m.get("connection_server2client_packetloss_total")); + if (loss >= 0) s.packetLoss = loss; + + long connected = parseLong(m.get("connection_connected_time")); + if (connected >= 0) s.connectedTimeMs = connected; + String ip = m.get("connection_client_ip"); + if (ip != null && !ip.isEmpty()) s.ip = ip; + + s.packetsSentTotal = pick(s.packetsSentTotal, m.get("connection_packets_sent_total")); + s.packetsReceivedTotal = pick(s.packetsReceivedTotal, m.get("connection_packets_received_total")); + s.bytesSentTotal = pick(s.bytesSentTotal, m.get("connection_bytes_sent_total")); + s.bytesReceivedTotal = pick(s.bytesReceivedTotal, m.get("connection_bytes_received_total")); + s.bandwidthSentLastSecond = + pick(s.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_total")); + s.bandwidthReceivedLastSecond = + pick(s.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_total")); + s.bandwidthSentLastMinute = + pick(s.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_total")); + s.bandwidthReceivedLastMinute = + pick(s.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_total")); + s.filetransferBandwidthSent = + pick(s.filetransferBandwidthSent, m.get("connection_filetransfer_bandwidth_sent")); + s.filetransferBandwidthReceived = + pick(s.filetransferBandwidthReceived, m.get("connection_filetransfer_bandwidth_received")); + + applyPerKindFields(s, m); + } + + /** + * Parses the per-category {@code connection_*_} fields (as sent in a + * {@code notifyconnectioninfo} report) into {@code s}, merging into any + * existing rows. Categories absent from the map are left untouched. + */ + private static void applyPerKindFields(ConnectionStats s, Map m) { + for (ConnectionStats.Kind kind : ConnectionStats.Kind.values()) { + String suffix = kind.name().toLowerCase(java.util.Locale.ROOT); // keepalive/control/speech + String probe = m.get("connection_packets_sent_" + suffix); + String probe2 = m.get("connection_server2client_packetloss_" + suffix); + if (probe == null && probe2 == null) continue; // this category not reported + + ConnectionStats.KindStats ks = s.getOrCreateKind(kind); + ks.packetsSent = pick(ks.packetsSent, m.get("connection_packets_sent_" + suffix)); + ks.packetsReceived = pick(ks.packetsReceived, m.get("connection_packets_received_" + suffix)); + ks.bytesSent = pick(ks.bytesSent, m.get("connection_bytes_sent_" + suffix)); + ks.bytesReceived = pick(ks.bytesReceived, m.get("connection_bytes_received_" + suffix)); + ks.bandwidthSentLastSecond = + pick(ks.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_" + suffix)); + ks.bandwidthReceivedLastSecond = + pick(ks.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_" + suffix)); + ks.bandwidthSentLastMinute = + pick(ks.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_" + suffix)); + ks.bandwidthReceivedLastMinute = + pick(ks.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_" + suffix)); + double kloss = parseDouble(m.get("connection_server2client_packetloss_" + suffix)); + if (kloss >= 0) ks.packetLoss = kloss; + } + } + + private static long pick(long current, String value) { + long v = parseLong(value); + return v >= 0 ? v : current; + } + + private static ConnectionStats.Kind mapKind(PacketKind kind) { + switch (kind) { + case KEEPALIVE: + return ConnectionStats.Kind.KEEPALIVE; + case SPEECH: + return ConnectionStats.Kind.SPEECH; + default: + return ConnectionStats.Kind.CONTROL; + } + } + + /** Parses a long, returning -1 for null/blank/non-numeric input. */ + private static long parseLong(String s) { + if (s == null || s.isEmpty()) return -1; + try { + return Long.parseLong(s.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + + /** Parses a double, returning -1 for null/blank/non-numeric input. */ + private static double parseDouble(String s) { + if (s == null || s.isEmpty()) return -1; + try { + return Double.parseDouble(s.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + + /** @return the parsed value, or 0 when it is absent or not a number */ + private static int safeInt(String value) { + try { + return value == null ? 0 : Integer.parseInt(value.trim()); + } catch (NumberFormatException ex) { + return 0; + } + } + + /** Callback + accumulating snapshot for an in-flight {@code getconnectioninfo}. */ + private static final class PendingConnInfo { + final Consumer callback; + final ConnectionStats stats; + + PendingConnInfo(Consumer callback, ConnectionStats stats) { + this.callback = callback; + this.stats = stats; + } + } +} 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 bdedc39..d994a5b 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 @@ -7,11 +7,8 @@ import com.github.manevolent.ts3j.command.SingleCommand; import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter; import com.github.manevolent.ts3j.event.*; import com.github.manevolent.ts3j.identity.LocalIdentity; -import com.github.manevolent.ts3j.protocol.PacketKind; import com.github.manevolent.ts3j.protocol.ProtocolRole; -import com.github.manevolent.ts3j.protocol.packet.statistics.PacketStatistics; import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket; -import com.github.manevolent.ts3j.util.Pair; import com.github.manevolent.ts3j.util.Ts3Crypt; import com.ts3client.audio.AudioBackend; import com.ts3client.audio.VoiceInput; @@ -52,11 +49,18 @@ public final class TeamspeakConnection implements TS3Listener { private final Settings settings; private final AudioBackend audio; private final ServerModel model = new ServerModel(); - private final ConnectionListener ui; + /** Package-private: read directly by {@link ConnectionEventHandler}. */ + final ConnectionListener ui; private final IconRepository icons; - private final SoundNotifier sounds; + /** Package-private: read directly by {@link ConnectionEventHandler}. */ + final SoundNotifier sounds; + /** The {@link TS3Listener} side of this connection; see its class doc. */ + private final ConnectionEventHandler events = new ConnectionEventHandler(this); + /** Builds {@code getconnectioninfo} snapshots; also package-private for {@link ConnectionEventHandler}. */ + final ConnectionStatsCollector stats = new ConnectionStatsCollector(this); - private LocalTeamspeakClientSocket client; + /** Package-private: read directly by {@link ConnectionStatsCollector}. */ + LocalTeamspeakClientSocket client; private VoiceInput microphone; private VoiceOutput playback; private LocalIdentity identity; @@ -64,7 +68,8 @@ public final class TeamspeakConnection implements TS3Listener { private volatile boolean connected; private volatile int selfClientId = -1; - private volatile long connectedAtMs; + /** Package-private: read directly by {@link ConnectionStatsCollector}. */ + volatile long connectedAtMs; private volatile String serverHost; private volatile int serverPort; @@ -83,9 +88,6 @@ public final class TeamspeakConnection implements TS3Listener { private volatile boolean disconnectAnnounced = true; private volatile long lastMutedTalkNanos; - /** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */ - private final Map pendingConnInfo = new ConcurrentHashMap<>(); - public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui, SoundNotifier sounds) { this.settings = settings; @@ -205,7 +207,7 @@ public final class TeamspeakConnection implements TS3Listener { model.clear(); playback = audio.createOutput(settings); - playback.setMasterVolume(settings.outputVolume); + playback.setMasterVolume(settings.effectiveOutputVolume()); playback.setTalkListener((clientId, talking) -> { ClientEntry c = model.getClient(clientId); if (c != null) c.talking = talking; @@ -233,6 +235,7 @@ public final class TeamspeakConnection implements TS3Listener { client.setNickname(nickname); client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode())); client.addListener(this); + client.addListener(events); client.setVoiceHandler(playback::handleVoice); client.setWhisperHandler(playback::handleWhisper); client.setExceptionHandler(t -> { @@ -486,7 +489,7 @@ public final class TeamspeakConnection implements TS3Listener { client = null; fileTransfers = null; selfClientId = -1; - pendingConnInfo.clear(); + stats.clear(); if (ft != null) { try { @@ -774,499 +777,6 @@ public final class TeamspeakConnection implements TS3Listener { }, "ts3j-rename").start(); } - // ---- TS3Listener: keep model in sync and notify UI ---- - - @Override - public void onClientJoin(ClientJoinEvent e) { - ClientEntry c = model.putClient(e.getClientId(), e.getClientNickname(), e.getClientTargetId()); - c.type = safeInt(e, "client_type"); - c.talkPower = e.getClientTalkPower(); - c.inputMuted = e.isClientInputMuted(); - c.outputMuted = e.isClientOutputMuted(); - c.inputHardware = e.isClientUsingHardwareInput(); - c.outputHardware = e.isClientUsingHardwareOutput(); - c.away = e.isClientAway(); - c.awayMessage = orEmpty(e.get("client_away_message")); - c.uniqueId = orEmpty(e.getUniqueClientIdentifier()); - c.serverGroupIds = parseIntList(e.getClientServerGroups()); - c.channelGroupId = e.getClientChannelGroupId(); - c.self = (e.getClientId() == selfClientId); - if (e.getClientId() != selfClientId) { - announceClientEntered(e); - logClientEntered(e); - } - ui.onModelChanged(); - } - - @Override - public void onClientLeave(ClientLeaveEvent e) { - if (e.getClientId() == selfClientId) { - announceOwnRemoval(safeInt(e, "reasonid"), e); - } else { - ClientEntry leaving = model.getClient(e.getClientId()); - String name = leaving != null ? leaving.nickname : "Client " + e.getClientId(); - announceClientLeft(e); - logClientLeft(e, name); - } - model.removeClient(e.getClientId()); - if (playback != null) playback.removeClient(e.getClientId()); - ui.onModelChanged(); - } - - @Override - public void onClientMoved(ClientMovedEvent e) { - ClientEntry c = model.getClient(e.getClientId()); - if (c != null) { - int from = c.channelId; - c.channelId = e.getTargetChannelId(); - if (e.getClientId() == selfClientId) { - announceOwnMove(safeInt(e, "reasonid"), e); - } else { - announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId()); - logClientMoved(e, c.nickname, from, e.getTargetChannelId()); - } - ui.onModelChanged(); - } - } - - /** A client became visible to us, logged the way native TS3's server tab does. */ - private void logClientEntered(ClientJoinEvent e) { - String name = e.getClientNickname(); - switch (safeInt(e, "reasonid")) { - case REASON_MOVED: - log(name + " appears, coming from channel \"" + channelName(e.getClientFromId()) + "\""); - break; - case REASON_CHANNEL_KICK: - log(name + " appears, was kicked from channel \"" + channelName(e.getClientFromId()) - + "\" by " + invokerName(e)); - break; - case REASON_SWITCHED: - log(name + " switched to channel \"" + channelName(e.getClientTargetId()) - + "\", coming from channel \"" + channelName(e.getClientFromId()) + "\""); - break; - default: - log(name + " connected to channel \"" + channelName(e.getClientTargetId()) + "\""); - } - } - - /** A client stopped being visible to us. */ - private void logClientLeft(ClientLeaveEvent e, String name) { - String reasonMsg = orEmpty(e.get("reasonmsg")); - String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")"; - switch (safeInt(e, "reasonid")) { - case REASON_TIMEOUT: - log(name + " dropped (ping timeout)"); - break; - case REASON_SERVER_KICK: - log(name + " was kicked from the server by " + invokerName(e) + suffix); - break; - case REASON_BAN: - log(name + " was banned from the server by " + invokerName(e) + suffix); - break; - case REASON_CHANNEL_KICK: - log(name + " left: was kicked to channel \"" + channelName(e.getClientTargetId()) - + "\" by " + invokerName(e) + suffix); - break; - case REASON_MOVED: - log(name + " left, heading to channel \"" + channelName(e.getClientTargetId()) + "\""); - break; - case REASON_SWITCHED: - log(name + " left, switched to channel \"" + channelName(e.getClientTargetId()) + "\""); - break; - default: - log(name + " disconnected"); - } - } - - /** A client we can see moved between two channels we can see. */ - private void logClientMoved(ClientMovedEvent e, String name, int fromChannel, int toChannel) { - String reasonMsg = orEmpty(e.get("reasonmsg")); - String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")"; - switch (safeInt(e, "reasonid")) { - case REASON_MOVED: - log(name + " was moved from channel \"" + channelName(fromChannel) + "\" to \"" - + channelName(toChannel) + "\" by " + invokerName(e)); - break; - case REASON_CHANNEL_KICK: - log(name + " was kicked from channel \"" + channelName(fromChannel) + "\" to \"" - + channelName(toChannel) + "\" by " + invokerName(e) + suffix); - break; - default: - log(name + " switched from channel \"" + channelName(fromChannel) + "\" to \"" - + channelName(toChannel) + "\""); - } - } - - // ---- who went where: TeamSpeak's reason ids ---- - - /** {@code reasonid} of a client view/move notification. */ - private static final int REASON_SWITCHED = 0; - private static final int REASON_MOVED = 1; - private static final int REASON_TIMEOUT = 3; - private static final int REASON_CHANNEL_KICK = 4; - private static final int REASON_SERVER_KICK = 5; - private static final int REASON_BAN = 6; - - /** - * A client became visible: either they just connected, or they moved in from a - * channel we could not see — TeamSpeak's "appears" case. - */ - private void announceClientEntered(ClientJoinEvent e) { - if (!connected) return; - int clientId = e.getClientId(); - boolean current = inOwnChannel(e.getClientTargetId()); - Map vars = clientVars(clientId, e.getClientNickname()); - switch (safeInt(e, "reasonid")) { - case REASON_MOVED: - sound(current ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_APPEARS - : SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_APPEARS, vars); - break; - case REASON_CHANNEL_KICK: - sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_APPEARS - : SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_APPEARS, vars); - break; - case REASON_SWITCHED: - sound(current ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS - : SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_APPEARS, vars); - break; - default: - sound(current ? SoundEvent.CLIENT_CONNECTION_CONNECTED_CURRENT_CHANNEL - : SoundEvent.CLIENT_CONNECTION_CONNECTED_SERVER, vars); - } - } - - /** A client stopped being visible: they left the server, or moved out of sight. */ - private void announceClientLeft(ClientLeaveEvent e) { - if (!connected) return; - int clientId = e.getClientId(); - boolean current = inOwnChannel(e.getClientFromId()); - Map vars = clientVars(clientId, null); - switch (safeInt(e, "reasonid")) { - case REASON_TIMEOUT: - sound(current ? SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_CURRENT_CHANNEL - : SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_SERVER, vars); - break; - case REASON_SERVER_KICK: - sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_CURRENT_CHANNEL - : SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_SERVER, vars); - break; - case REASON_BAN: - sound(current ? SoundEvent.CLIENT_WAS_BANNED_CURRENT_CHANNEL - : SoundEvent.CLIENT_WAS_BANNED_SERVER, vars); - break; - case REASON_CHANNEL_KICK: - sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_DISAPPEARS - : SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_DISAPPEARS, vars); - break; - case REASON_MOVED: - sound(current ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_DISAPPEARS - : SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_DISAPPEARS, vars); - break; - case REASON_SWITCHED: - sound(current ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_DISAPPEARS - : SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_DISAPPEARS, vars); - break; - default: - sound(current ? SoundEvent.CLIENT_CONNECTION_DISCONNECTED_CURRENT_CHANNEL - : SoundEvent.CLIENT_CONNECTION_DISCONNECTED_SERVER, vars); - } - } - - /** A client we can see moved between two channels we can see ("stays"). */ - private void announceClientMoved(int reason, int clientId, int fromChannel, int toChannel) { - if (!connected) return; - boolean toCurrent = inOwnChannel(toChannel); - boolean fromCurrent = inOwnChannel(fromChannel); - Map vars = clientVars(clientId, null); - switch (reason) { - case REASON_MOVED: - sound(toCurrent ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS - : fromCurrent ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_STAYS - : SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_STAYS, vars); - break; - case REASON_CHANNEL_KICK: - sound(toCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_STAYS - : fromCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_STAYS - : SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_STAYS, vars); - break; - default: - sound(toCurrent ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_STAYS - : fromCurrent ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_STAYS - : SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_STAYS, vars); - } - } - - /** We changed channel ourselves, or somebody changed it for us. */ - private void announceOwnMove(int reason, ClientMovedEvent e) { - if (!connected) return; - Map vars = channelVars(e.getTargetChannelId(), e.get("invokername")); - switch (reason) { - case REASON_MOVED: - sound(SoundEvent.YOU_WERE_MOVED_TO_DIFFERENT_CHANNEL, vars); - break; - case REASON_CHANNEL_KICK: - sound(SoundEvent.YOU_WERE_KICKED_FROM_CHANNEL, vars); - break; - default: - sound(SoundEvent.YOU_SWITCHED_CHANNEL, vars); - } - } - - /** We were removed from the server (kick or ban); the disconnect follows. */ - private void announceOwnRemoval(int reason, ClientLeaveEvent e) { - if (!connected) return; - Map vars = SoundNotifier.vars( - "servername", model.getServerName(), - "clientname", orEmpty(e.get("invokername")), - "reason", orEmpty(e.get("reasonmsg"))); - if (reason == REASON_SERVER_KICK) { - sound(SoundEvent.YOU_WERE_KICKED_FROM_SERVER, vars); - } else if (reason == REASON_BAN) { - sound(SoundEvent.YOU_WERE_BANNED, vars); - } - } - - @Override - public void onClientChanged(ClientUpdatedEvent e) { - ClientEntry c = model.getClient(e.getClientId()); - if (c == null) return; - boolean renamed = has(e, "client_nickname") && !e.get("client_nickname").equals(c.nickname); - String oldName = c.nickname; - if (renamed) c.nickname = e.get("client_nickname"); - if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted"); - if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted"); - if (has(e, "client_input_hardware")) c.inputHardware = e.getBoolean("client_input_hardware"); - if (has(e, "client_output_hardware")) c.outputHardware = e.getBoolean("client_output_hardware"); - if (has(e, "client_away")) { - c.away = e.getBoolean("client_away"); - // Both fields travel together, so an absent message here means "no message" - // — which `has` cannot tell from "not reported". - c.awayMessage = c.away ? orEmpty(e.get("client_away_message")) : ""; - } else if (has(e, "client_away_message")) { - c.awayMessage = e.get("client_away_message"); - } - if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power"); - if (has(e, "client_is_channel_commander")) - c.channelCommander = e.getBoolean("client_is_channel_commander"); - announceClientUpdate(e, c, renamed, oldName); - ui.onModelChanged(); - } - - /** Renames, talk-power changes and recording flags all arrive as a client update. */ - private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed, String oldName) { - if (!connected) return; - boolean self = c.id == selfClientId; - Map vars = clientVars(c.id, null); - - if (renamed) { - sound(safeInt(e, "invokerid") == selfClientId - ? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars); - log(oldName + " is now known as " + c.nickname); - } - if (safeInt(e, "client_talk_request") > 0 && !self) { - sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars); - } - if (self && has(e, "client_is_talker")) { - sound(e.getBoolean("client_is_talker") - ? SoundEvent.YOU_WERE_GRANTED_TALK_POWER : SoundEvent.YOU_WERE_REVOKED_TALK_POWER, vars); - } - if (!self && has(e, "client_is_recording")) { - boolean recording = e.getBoolean("client_is_recording"); - if (!recording) { - sound(SoundEvent.CLIENT_RECORDING_STOP, vars); - } else { - sound(inOwnChannel(c.channelId) - ? SoundEvent.CLIENT_RECORDING_IN_CHANNEL : SoundEvent.CLIENT_RECORDING_START, vars); - } - } - } - - @Override - public void onChannelCreate(ChannelCreateEvent e) { - int cid = e.getChannelId(); - String name = e.get("channel_name"); - int pid = safeInt(e, "cpid"); - if (pid == 0) pid = safeInt(e, "pid"); - int order = safeInt(e, "channel_order"); - ChannelNode node = model.putChannel(cid, name, pid, order); - long icon = safeLong(e, "channel_icon_id"); - if (icon != 0) node.iconId = icon; - model.relinkChannel(cid, pid, order); - if (connected) { - sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER, - SoundEvent.CHANNEL_CREATED_BY_OTHER), channelVars(cid, e.get("invokername"))); - log("Channel \"" + name + "\" was created by " + invokerName(e)); - } - ui.onModelChanged(); - } - - @Override - public void onChannelDeleted(ChannelDeletedEvent e) { - int cid = e.getChannelId(); - if (connected) { - sound(byInvoker(e, SoundEvent.CHANNEL_DELETED_BY_YOU, SoundEvent.CHANNEL_DELETED_BY_OTHER, - SoundEvent.CHANNEL_DELETED_BY_SERVER), channelVars(cid, e.get("invokername"))); - log("Channel \"" + channelName(cid) + "\" was deleted by " + invokerName(e)); - } - model.removeChannel(cid); - ui.onModelChanged(); - } - - @Override - public void onChannelEdit(ChannelEditedEvent e) { - ChannelNode ch = model.getChannel(safeInt(e, "cid")); - if (ch != null) { - if (has(e, "channel_name")) ch.name = e.get("channel_name"); - if (has(e, "channel_order")) ch.order = e.getInt("channel_order"); - if (has(e, "channel_icon_id")) ch.iconId = safeLong(e, "channel_icon_id"); - if (connected) { - boolean current = inOwnChannel(ch.id); - sound(current - ? byInvoker(e, SoundEvent.CHANNEL_EDITED_CURRENT_BY_YOU, - SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER, SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER) - : byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU, - SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER), - channelVars(ch.id, e.get("invokername"))); - log("Channel \"" + ch.name + "\" was edited by " + invokerName(e)); - } - ui.onModelChanged(); - } - } - - @Override - public void onChannelMoved(ChannelMovedEvent e) { - ChannelNode ch = model.getChannel(safeInt(e, "cid")); - if (ch != null) { - int parent = has(e, "cpid") ? e.getInt("cpid") : ch.parentId; - int order = has(e, "order") ? e.getInt("order") : ch.order; - model.relinkChannel(ch.id, parent, order); - if (connected) { - sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER, - SoundEvent.CHANNEL_MOVED_BY_OTHER), channelVars(ch.id, e.get("invokername"))); - log("Channel \"" + ch.name + "\" was moved by " + invokerName(e)); - } - ui.onModelChanged(); - } - } - - @Override - public void onChannelSubscribed(ChannelSubscribedEvent e) { - setSubscribed(safeInt(e, "cid"), true); - } - - @Override - public void onChannelUnsubscribed(ChannelUnsubscribedEvent e) { - setSubscribed(safeInt(e, "cid"), false); - } - - private void setSubscribed(int cid, boolean subscribed) { - ChannelNode ch = model.getChannel(cid); - if (ch == null || ch.subscribed == subscribed) return; - ch.subscribed = subscribed; - ui.onModelChanged(); - } - - @Override - public void onServerEdit(ServerEditedEvent e) { - if (has(e, "virtualserver_name")) model.setServerName(e.get("virtualserver_name")); - if (connected) { - sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER, - SoundEvent.SERVER_EDITED_BY_OTHER), serverVars()); - } - ui.onModelChanged(); - } - - @Override - public void onServerGroupClientAdded(ServerGroupClientAddedEvent e) { - boolean self = e.getClientId() == selfClientId; - sound(self - ? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, - SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER) - : byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, - SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_SERVER), - groupVars(e.getClientId(), e.getName())); - log(clientLogName(e.getClientId()) + " was added to server group \"" + e.getName() - + "\" by " + invokerName(e) + "."); - } - - @Override - public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) { - int clientId = safeInt(e, "clid"); - boolean self = clientId == selfClientId; - sound(self - ? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, - SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER) - : byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, - SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_SERVER), - groupVars(clientId, e.get("name"))); - log(clientLogName(clientId) + " was removed from server group \"" + orEmpty(e.get("name")) - + "\" by " + invokerName(e) + "."); - } - - @Override - public void onClientChannelGroupChanged(ClientChannelGroupChangedEvent e) { - ClientEntry c = model.getClient(e.getClientId()); - if (c != null) c.channelGroupId = e.getChannelGroupId(); - boolean self = e.getClientId() == selfClientId; - String groupName = model.channelGroupName(e.getChannelGroupId()); - sound(self - ? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, - SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_SERVER) - : byInvoker(e, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, - SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_SERVER), - groupVars(e.getClientId(), groupName)); - log("Channel group \"" + orEmpty(groupName) + "\" was assigned to " + clientLogName(e.getClientId()) - + " by " + invokerName(e) + "."); - ui.onModelChanged(); - } - - /** A client's nickname for a log line, falling back to its id once it has left. */ - private String clientLogName(int clientId) { - ClientEntry c = model.getClient(clientId); - return c != null ? c.nickname : "Client " + clientId; - } - - /** Picks the event variant matching who caused the change: us, another client, or the server. */ - private SoundEvent byInvoker(BaseEvent e, SoundEvent byYou, SoundEvent byOther, SoundEvent byServer) { - int invoker = safeInt(e, "invokerid"); - if (invoker == selfClientId && invoker != 0) return byYou; - return invoker == 0 ? byServer : byOther; - } - - private Map groupVars(int clientId, String groupName) { - Map vars = clientVars(clientId, null); - vars.put("groupname", orEmpty(groupName)); - return vars; - } - - @Override - public void onChannelList(ChannelListEvent e) { - // Incremental channel arriving during connect. - int cid = e.getChannelId(); - String name = e.get("channel_name"); - int pid = safeInt(e, "cpid"); - int order = safeInt(e, "channel_order"); - ChannelNode node = model.putChannel(cid, name, pid, order); - long icon = safeLong(e, "channel_icon_id"); - if (icon != 0) node.iconId = icon; - } - - @Override - public void onServerGroupList(ServerGroupListEvent e) { - int id = safeInt(e, "sgid"); - if (id > 0) model.putServerGroup(toGroup(e, id)); - } - - @Override - public void onChannelGroupList(ChannelGroupListEvent e) { - int id = safeInt(e, "cgid"); - if (id > 0) model.putChannelGroup(toGroup(e, id)); - } - - private static Group toGroup(BaseEvent e, int id) { - return new Group(id, e.get("name"), safeLong(e, "iconid"), safeInt(e, "sortid")); - } - /** Fetches a channel's description (and topic) on demand, then notifies the UI. */ public void requestChannelInfo(int channelId) { new Thread(() -> { @@ -1405,262 +915,17 @@ public final class TeamspeakConnection implements TS3Listener { */ public void requestConnectionInfo(int clientId, Consumer callback) { if (client == null || !connected) return; - if (clientId == selfClientId) { - new Thread(() -> callback.accept(buildLocalStats()), "ts3j-conninfo-self").start(); - } else { - new Thread(() -> requestRemoteConnInfo(clientId, callback), "ts3j-conninfo").start(); - } + stats.request(clientId, callback); } - /** Builds a live snapshot of the local client's connection from its own counters. */ - private ConnectionStats buildLocalStats() { - ConnectionStats s = new ConnectionStats(); - s.clientId = selfClientId; - s.self = true; - s.live = true; - s.packetLoss = 0; // the local client has no server->client loss figure - - ClientEntry self = model.getClient(selfClientId); - if (self != null) { - s.nickname = self.nickname; - s.version = self.version; - s.platform = self.platform; - s.idleTimeMs = self.idleTimeMs; - } - long connectedAt = connectedAtMs; - if (connectedAt > 0) s.connectedTimeMs = System.currentTimeMillis() - connectedAt; - - LocalTeamspeakClientSocket c = client; - if (c == null) return s; - - try { - Pair ping = c.getPing(); - s.pingMs = ping.getKey() * 1000.0; - s.pingDeviationMs = ping.getValue() * 1000.0; - } catch (Exception ignored) { - // ping unavailable; leave as unknown - } - - long pSent = 0, pRecv = 0, bSent = 0, bRecv = 0; - long bwSs = 0, bwRs = 0, bwSm = 0, bwRm = 0; - for (PacketKind kind : PacketKind.values()) { - PacketStatistics st = c.getStatistics(kind); - ConnectionStats.KindStats ks = new ConnectionStats.KindStats(mapKind(kind)); - ks.packetLoss = 0; // the local client cannot measure its own server->client loss - ks.packetsSent = st.getSentPackets(); - ks.packetsReceived = st.getReceivedPackets(); - ks.bytesSent = st.getSentBytes(); - ks.bytesReceived = st.getReceivedBytes(); - ks.bandwidthSentLastSecond = st.getSentBytesLastSecond(); - ks.bandwidthReceivedLastSecond = st.getReceivedBytesLastSecond(); - ks.bandwidthSentLastMinute = st.getSentBytesLastMinute(); - ks.bandwidthReceivedLastMinute = st.getReceivedBytesLastMinute(); - s.perKind.add(ks); - - pSent += ks.packetsSent; - pRecv += ks.packetsReceived; - bSent += ks.bytesSent; - bRecv += ks.bytesReceived; - bwSs += ks.bandwidthSentLastSecond; - bwRs += ks.bandwidthReceivedLastSecond; - bwSm += ks.bandwidthSentLastMinute; - bwRm += ks.bandwidthReceivedLastMinute; - } - s.packetsSentTotal = pSent; - s.packetsReceivedTotal = pRecv; - s.bytesSentTotal = bSent; - s.bytesReceivedTotal = bRecv; - s.bandwidthSentLastSecond = bwSs; - s.bandwidthReceivedLastSecond = bwRs; - s.bandwidthSentLastMinute = bwSm; - s.bandwidthReceivedLastMinute = bwRm; - return s; - } + // ---- events not handled by ConnectionEventHandler ---- /** - * Requests a remote client's connection info. First loads {@code clientinfo} - * for the stable fields, then issues {@code getconnectioninfo} whose - * {@code notifyconnectioninfo} report arrives asynchronously via - * {@link #onUnknownEvent}. If the report does not arrive shortly (e.g. the - * server withholds it), the clientinfo-only snapshot is delivered instead. + * Kept here rather than in {@link ConnectionEventHandler}: teardown needs + * {@link #safeCleanup()} and the {@code connected}/announced flags this class + * already owns, so routing it through the event handler would only add an + * extra hop back into private lifecycle state. */ - private void requestRemoteConnInfo(int clientId, Consumer callback) { - ConnectionStats s = new ConnectionStats(); - s.clientId = clientId; - ClientEntry entry = model.getClient(clientId); - if (entry != null) s.nickname = entry.nickname; - - try { - Client c = client.getClientInfo(clientId); - if (c != null) { - s.version = orEmpty(c.getVersion()); - s.platform = orEmpty(c.getPlatform()); - s.ip = orEmpty(c.getIp()); - s.idleTimeMs = c.getIdleTime(); - if (entry == null) s.nickname = orEmpty(c.getNickname()); - applyConnectionFields(s, c.getMap()); - } - } catch (Exception ignored) { - // clientinfo may be permission-restricted; continue with what we have - } - - PendingConnInfo pending = new PendingConnInfo(callback, s); - pendingConnInfo.put(clientId, pending); - - boolean sent = false; - try { - SingleCommand cmd = new SingleCommand("getconnectioninfo", ProtocolRole.CLIENT, - new CommandSingleParameter("clid", Integer.toString(clientId))); - client.executeCommand(cmd).complete(); - sent = true; - } catch (Exception ignored) { - // command failed; fall back to the clientinfo snapshot below - } - - if (sent) { - try { - Thread.sleep(700); // give notifyconnectioninfo a chance to arrive - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - } - // If the report already arrived, onUnknownEvent removed and delivered it. - if (pendingConnInfo.remove(clientId, pending)) { - callback.accept(s); - } - } - - /** Parses TeamSpeak {@code connection_*} fields from a command map into {@code s}. */ - private static void applyConnectionFields(ConnectionStats s, Map m) { - double ping = parseDouble(m.get("connection_ping")); - if (ping >= 0) s.pingMs = ping; - double dev = parseDouble(m.get("connection_ping_deviation")); - if (dev >= 0) s.pingDeviationMs = dev; - double loss = parseDouble(m.get("connection_packetloss_total")); - if (loss < 0) loss = parseDouble(m.get("connection_server2client_packetloss_total")); - if (loss >= 0) s.packetLoss = loss; - - long connected = parseLong(m.get("connection_connected_time")); - if (connected >= 0) s.connectedTimeMs = connected; - String ip = m.get("connection_client_ip"); - if (ip != null && !ip.isEmpty()) s.ip = ip; - - s.packetsSentTotal = pick(s.packetsSentTotal, m.get("connection_packets_sent_total")); - s.packetsReceivedTotal = pick(s.packetsReceivedTotal, m.get("connection_packets_received_total")); - s.bytesSentTotal = pick(s.bytesSentTotal, m.get("connection_bytes_sent_total")); - s.bytesReceivedTotal = pick(s.bytesReceivedTotal, m.get("connection_bytes_received_total")); - s.bandwidthSentLastSecond = - pick(s.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_total")); - s.bandwidthReceivedLastSecond = - pick(s.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_total")); - s.bandwidthSentLastMinute = - pick(s.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_total")); - s.bandwidthReceivedLastMinute = - pick(s.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_total")); - s.filetransferBandwidthSent = - pick(s.filetransferBandwidthSent, m.get("connection_filetransfer_bandwidth_sent")); - s.filetransferBandwidthReceived = - pick(s.filetransferBandwidthReceived, m.get("connection_filetransfer_bandwidth_received")); - - applyPerKindFields(s, m); - } - - /** - * Parses the per-category {@code connection_*_} fields (as sent in a - * {@code notifyconnectioninfo} report) into {@code s}, merging into any - * existing rows. Categories absent from the map are left untouched. - */ - private static void applyPerKindFields(ConnectionStats s, Map m) { - for (ConnectionStats.Kind kind : ConnectionStats.Kind.values()) { - String suffix = kind.name().toLowerCase(java.util.Locale.ROOT); // keepalive/control/speech - String probe = m.get("connection_packets_sent_" + suffix); - String probe2 = m.get("connection_server2client_packetloss_" + suffix); - if (probe == null && probe2 == null) continue; // this category not reported - - ConnectionStats.KindStats ks = s.getOrCreateKind(kind); - ks.packetsSent = pick(ks.packetsSent, m.get("connection_packets_sent_" + suffix)); - ks.packetsReceived = pick(ks.packetsReceived, m.get("connection_packets_received_" + suffix)); - ks.bytesSent = pick(ks.bytesSent, m.get("connection_bytes_sent_" + suffix)); - ks.bytesReceived = pick(ks.bytesReceived, m.get("connection_bytes_received_" + suffix)); - ks.bandwidthSentLastSecond = - pick(ks.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_" + suffix)); - ks.bandwidthReceivedLastSecond = - pick(ks.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_" + suffix)); - ks.bandwidthSentLastMinute = - pick(ks.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_" + suffix)); - ks.bandwidthReceivedLastMinute = - pick(ks.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_" + suffix)); - double kloss = parseDouble(m.get("connection_server2client_packetloss_" + suffix)); - if (kloss >= 0) ks.packetLoss = kloss; - } - } - - private static long pick(long current, String value) { - long v = parseLong(value); - return v >= 0 ? v : current; - } - - private static ConnectionStats.Kind mapKind(PacketKind kind) { - switch (kind) { - case KEEPALIVE: - return ConnectionStats.Kind.KEEPALIVE; - case SPEECH: - return ConnectionStats.Kind.SPEECH; - default: - return ConnectionStats.Kind.CONTROL; - } - } - - @Override - public void onUnknownEvent(UnknownTeamspeakEvent e) { - if (!"notifyconnectioninfo".equals(e.getCommand())) return; - int clid = safeInt(e, "clid"); - PendingConnInfo pending = pendingConnInfo.remove(clid); - if (pending == null) return; - applyConnectionFields(pending.stats, e.getMap()); - pending.callback.accept(pending.stats); - } - - /** Callback + accumulating snapshot for an in-flight {@code getconnectioninfo}. */ - private static final class PendingConnInfo { - final Consumer callback; - final ConnectionStats stats; - - PendingConnInfo(Consumer callback, ConnectionStats stats) { - this.callback = callback; - this.stats = stats; - } - } - - @Override - public void onTextMessage(TextMessageEvent e) { - if (e.getInvokerId() == selfClientId) return; // don't echo our own - ConnectionListener.ChatScope scope; - SoundEvent notification; - switch (e.getTargetMode()) { - case CLIENT: - scope = ConnectionListener.ChatScope.PRIVATE; - notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CLIENT; - break; - case CHANNEL: - scope = ConnectionListener.ChatScope.CHANNEL; - notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL; - break; - default: - scope = ConnectionListener.ChatScope.SERVER; - notification = SoundEvent.CHAT_RECEIVED_MESSAGE_SERVER; - break; - } - sound(notification, clientVars(e.getInvokerId(), e.getInvokerName())); - ui.onChat(scope, e.getInvokerId(), e.getInvokerName(), e.getMessage()); - } - - @Override - public void onClientPoke(ClientPokeEvent e) { - sound(SoundEvent.OTHER_RECEIVED_POKE, clientVars(e.getInvokerId(), e.getInvokerName())); - ui.onPoke(orEmpty(e.getInvokerName()), orEmpty(e.get("msg"))); - } - @Override public void onDisconnected(DisconnectedEvent e) { connected = false; @@ -1674,33 +939,29 @@ public final class TeamspeakConnection implements TS3Listener { /** * Plays the sound pack's entry for an action. Muting is passed along so the - * notifier can drop everything the user did not mark as important. + * notifier can drop everything the user did not mark as important. Package-private: + * also called by {@link ConnectionEventHandler}. */ - private void sound(SoundEvent event) { + void sound(SoundEvent event) { sounds.fire(event, deafened); } - private void sound(SoundEvent event, Map variables) { + void sound(SoundEvent event, Map variables) { sounds.fire(event, deafened, variables); } /** Records a line in the server tab's log, the way native TS3 reports server activity. */ - private void log(String message) { + void log(String message) { if (connected) ui.onServerLog(message); } - private String channelName(int channelId) { + String channelName(int channelId) { ChannelNode ch = model.getChannel(channelId); return ch != null ? ch.name : "channel #" + channelId; } - private static String invokerName(BaseEvent e) { - String name = orEmpty(e.get("invokername")); - return name.isEmpty() ? "the server" : name; - } - /** The placeholder values a pack may reference for an action involving a client. */ - private Map clientVars(int clientId, String fallbackName) { + Map clientVars(int clientId, String fallbackName) { ClientEntry c = model.getClient(clientId); Map vars = SoundNotifier.vars( "servername", model.getServerName(), @@ -1713,7 +974,7 @@ public final class TeamspeakConnection implements TS3Listener { return vars; } - private Map channelVars(int channelId, String invokerName) { + Map channelVars(int channelId, String invokerName) { ChannelNode ch = model.getChannel(channelId); return SoundNotifier.vars( "servername", model.getServerName(), @@ -1721,7 +982,7 @@ public final class TeamspeakConnection implements TS3Listener { "clientname", orEmpty(invokerName)); } - private Map serverVars() { + Map serverVars() { return SoundNotifier.vars("servername", model.getServerName()); } @@ -1739,15 +1000,17 @@ public final class TeamspeakConnection implements TS3Listener { return m.contains("insufficient") || m.contains("permission"); } - /** Whether the client is in the channel we are in ourselves. */ - private boolean inOwnChannel(int channelId) { + /** Whether the client is in the channel we are in ourselves. Package-private: also + * used by {@link ConnectionEventHandler}. */ + boolean inOwnChannel(int channelId) { ClientEntry self = model.getClient(selfClientId); return self != null && self.channelId == channelId; } // ---- helpers ---- - private static long safeLong(BaseEvent e, String key) { + /** Package-private: also used by {@link ConnectionEventHandler}. */ + static long safeLong(BaseEvent e, String key) { return safeLong(e.get(key)); } @@ -1760,65 +1023,11 @@ public final class TeamspeakConnection implements TS3Listener { } } - /** - * Whether the event actually carries a field. ts3j answers a missing key with an - * empty string rather than null, so a plain null check is always true — and a - * partial update (say, someone muting) would otherwise look like it reported - * every other field as well. - */ - private static boolean has(BaseEvent e, String key) { - String value = e.get(key); - return value != null && !value.isEmpty(); - } - - private static int safeInt(BaseEvent e, String key) { - try { - String v = e.get(key); - return v == null ? 0 : Integer.parseInt(v.trim()); - } catch (Exception ex) { - return 0; - } - } - - private static String orEmpty(String s) { + /** Package-private: also used by {@link ConnectionEventHandler} and {@link ConnectionStatsCollector}. */ + static String orEmpty(String s) { return s == null ? "" : s; } - /** Parses a long, returning -1 for null/blank/non-numeric input. */ - private static long parseLong(String s) { - if (s == null || s.isEmpty()) return -1; - try { - return Long.parseLong(s.trim()); - } catch (NumberFormatException e) { - return -1; - } - } - - /** Parses a double, returning -1 for null/blank/non-numeric input. */ - private static double parseDouble(String s) { - if (s == null || s.isEmpty()) return -1; - try { - return Double.parseDouble(s.trim()); - } catch (NumberFormatException e) { - return -1; - } - } - - /** Parses a comma-separated id list (e.g. server groups "6,12,15"). */ - private static int[] parseIntList(String csv) { - if (csv == null || csv.isEmpty()) return new int[0]; - String[] parts = csv.split(","); - int[] out = new int[parts.length]; - int n = 0; - for (String p : parts) { - try { - out[n++] = Integer.parseInt(p.trim()); - } catch (NumberFormatException ignored) { - } - } - return n == parts.length ? out : java.util.Arrays.copyOf(out, n); - } - private static String rootMessage(Throwable t) { Throwable r = t; while (r.getCause() != null && r.getCause() != r) r = r.getCause(); diff --git a/ts3-client/core/src/test/java/com/ts3client/net/EventFieldsTest.java b/ts3-client/core/src/test/java/com/ts3client/net/EventFieldsTest.java index 9503a1f..f38c1d5 100644 --- a/ts3-client/core/src/test/java/com/ts3client/net/EventFieldsTest.java +++ b/ts3-client/core/src/test/java/com/ts3client/net/EventFieldsTest.java @@ -9,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; /** - * Pins the ts3j behaviour {@code TeamspeakConnection.has(...)} exists for: a field + * Pins the ts3j behaviour {@code ConnectionEventHandler.has(...)} exists for: a field * the event never carried reads back as an empty string, so a null check would treat * every partial update (someone muting, say) as if it reported every other field too. */