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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> groupVars(int clientId, String groupName) {
|
||||||
|
Map<String, String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Integer, PendingConnInfo> 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<ConnectionStats> 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<Double, Double> 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<ConnectionStats> 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<String, String> 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_*_<kind>} 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<String, String> 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<ConnectionStats> callback;
|
||||||
|
final ConnectionStats stats;
|
||||||
|
|
||||||
|
PendingConnInfo(Consumer<ConnectionStats> callback, ConnectionStats stats) {
|
||||||
|
this.callback = callback;
|
||||||
|
this.stats = stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,8 @@ import com.github.manevolent.ts3j.command.SingleCommand;
|
|||||||
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
|
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
|
||||||
import com.github.manevolent.ts3j.event.*;
|
import com.github.manevolent.ts3j.event.*;
|
||||||
import com.github.manevolent.ts3j.identity.LocalIdentity;
|
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.ProtocolRole;
|
||||||
import com.github.manevolent.ts3j.protocol.packet.statistics.PacketStatistics;
|
|
||||||
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
|
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.github.manevolent.ts3j.util.Ts3Crypt;
|
||||||
import com.ts3client.audio.AudioBackend;
|
import com.ts3client.audio.AudioBackend;
|
||||||
import com.ts3client.audio.VoiceInput;
|
import com.ts3client.audio.VoiceInput;
|
||||||
@@ -52,11 +49,18 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
private final Settings settings;
|
private final Settings settings;
|
||||||
private final AudioBackend audio;
|
private final AudioBackend audio;
|
||||||
private final ServerModel model = new ServerModel();
|
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 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 VoiceInput microphone;
|
||||||
private VoiceOutput playback;
|
private VoiceOutput playback;
|
||||||
private LocalIdentity identity;
|
private LocalIdentity identity;
|
||||||
@@ -64,7 +68,8 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
|
|
||||||
private volatile boolean connected;
|
private volatile boolean connected;
|
||||||
private volatile int selfClientId = -1;
|
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 String serverHost;
|
||||||
private volatile int serverPort;
|
private volatile int serverPort;
|
||||||
|
|
||||||
@@ -83,9 +88,6 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
private volatile boolean disconnectAnnounced = true;
|
private volatile boolean disconnectAnnounced = true;
|
||||||
private volatile long lastMutedTalkNanos;
|
private volatile long lastMutedTalkNanos;
|
||||||
|
|
||||||
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
|
|
||||||
private final Map<Integer, PendingConnInfo> pendingConnInfo = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui,
|
public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui,
|
||||||
SoundNotifier sounds) {
|
SoundNotifier sounds) {
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
@@ -205,7 +207,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
|
|
||||||
model.clear();
|
model.clear();
|
||||||
playback = audio.createOutput(settings);
|
playback = audio.createOutput(settings);
|
||||||
playback.setMasterVolume(settings.outputVolume);
|
playback.setMasterVolume(settings.effectiveOutputVolume());
|
||||||
playback.setTalkListener((clientId, talking) -> {
|
playback.setTalkListener((clientId, talking) -> {
|
||||||
ClientEntry c = model.getClient(clientId);
|
ClientEntry c = model.getClient(clientId);
|
||||||
if (c != null) c.talking = talking;
|
if (c != null) c.talking = talking;
|
||||||
@@ -233,6 +235,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
client.setNickname(nickname);
|
client.setNickname(nickname);
|
||||||
client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode()));
|
client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode()));
|
||||||
client.addListener(this);
|
client.addListener(this);
|
||||||
|
client.addListener(events);
|
||||||
client.setVoiceHandler(playback::handleVoice);
|
client.setVoiceHandler(playback::handleVoice);
|
||||||
client.setWhisperHandler(playback::handleWhisper);
|
client.setWhisperHandler(playback::handleWhisper);
|
||||||
client.setExceptionHandler(t -> {
|
client.setExceptionHandler(t -> {
|
||||||
@@ -486,7 +489,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
client = null;
|
client = null;
|
||||||
fileTransfers = null;
|
fileTransfers = null;
|
||||||
selfClientId = -1;
|
selfClientId = -1;
|
||||||
pendingConnInfo.clear();
|
stats.clear();
|
||||||
|
|
||||||
if (ft != null) {
|
if (ft != null) {
|
||||||
try {
|
try {
|
||||||
@@ -774,499 +777,6 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
}, "ts3j-rename").start();
|
}, "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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> groupVars(int clientId, String groupName) {
|
|
||||||
Map<String, String> 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. */
|
/** Fetches a channel's description (and topic) on demand, then notifies the UI. */
|
||||||
public void requestChannelInfo(int channelId) {
|
public void requestChannelInfo(int channelId) {
|
||||||
new Thread(() -> {
|
new Thread(() -> {
|
||||||
@@ -1405,262 +915,17 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
*/
|
*/
|
||||||
public void requestConnectionInfo(int clientId, Consumer<ConnectionStats> callback) {
|
public void requestConnectionInfo(int clientId, Consumer<ConnectionStats> callback) {
|
||||||
if (client == null || !connected) return;
|
if (client == null || !connected) return;
|
||||||
if (clientId == selfClientId) {
|
stats.request(clientId, callback);
|
||||||
new Thread(() -> callback.accept(buildLocalStats()), "ts3j-conninfo-self").start();
|
|
||||||
} else {
|
|
||||||
new Thread(() -> requestRemoteConnInfo(clientId, callback), "ts3j-conninfo").start();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Builds a live snapshot of the local client's connection from its own counters. */
|
// ---- events not handled by ConnectionEventHandler ----
|
||||||
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<Double, Double> 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}
|
* Kept here rather than in {@link ConnectionEventHandler}: teardown needs
|
||||||
* for the stable fields, then issues {@code getconnectioninfo} whose
|
* {@link #safeCleanup()} and the {@code connected}/announced flags this class
|
||||||
* {@code notifyconnectioninfo} report arrives asynchronously via
|
* already owns, so routing it through the event handler would only add an
|
||||||
* {@link #onUnknownEvent}. If the report does not arrive shortly (e.g. the
|
* extra hop back into private lifecycle state.
|
||||||
* server withholds it), the clientinfo-only snapshot is delivered instead.
|
|
||||||
*/
|
*/
|
||||||
private void requestRemoteConnInfo(int clientId, Consumer<ConnectionStats> 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<String, String> 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_*_<kind>} 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<String, String> 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<ConnectionStats> callback;
|
|
||||||
final ConnectionStats stats;
|
|
||||||
|
|
||||||
PendingConnInfo(Consumer<ConnectionStats> 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
|
@Override
|
||||||
public void onDisconnected(DisconnectedEvent e) {
|
public void onDisconnected(DisconnectedEvent e) {
|
||||||
connected = false;
|
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
|
* 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);
|
sounds.fire(event, deafened);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sound(SoundEvent event, Map<String, String> variables) {
|
void sound(SoundEvent event, Map<String, String> variables) {
|
||||||
sounds.fire(event, deafened, variables);
|
sounds.fire(event, deafened, variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Records a line in the server tab's log, the way native TS3 reports server activity. */
|
/** 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);
|
if (connected) ui.onServerLog(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String channelName(int channelId) {
|
String channelName(int channelId) {
|
||||||
ChannelNode ch = model.getChannel(channelId);
|
ChannelNode ch = model.getChannel(channelId);
|
||||||
return ch != null ? ch.name : "channel #" + 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. */
|
/** The placeholder values a pack may reference for an action involving a client. */
|
||||||
private Map<String, String> clientVars(int clientId, String fallbackName) {
|
Map<String, String> clientVars(int clientId, String fallbackName) {
|
||||||
ClientEntry c = model.getClient(clientId);
|
ClientEntry c = model.getClient(clientId);
|
||||||
Map<String, String> vars = SoundNotifier.vars(
|
Map<String, String> vars = SoundNotifier.vars(
|
||||||
"servername", model.getServerName(),
|
"servername", model.getServerName(),
|
||||||
@@ -1713,7 +974,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
return vars;
|
return vars;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, String> channelVars(int channelId, String invokerName) {
|
Map<String, String> channelVars(int channelId, String invokerName) {
|
||||||
ChannelNode ch = model.getChannel(channelId);
|
ChannelNode ch = model.getChannel(channelId);
|
||||||
return SoundNotifier.vars(
|
return SoundNotifier.vars(
|
||||||
"servername", model.getServerName(),
|
"servername", model.getServerName(),
|
||||||
@@ -1721,7 +982,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
"clientname", orEmpty(invokerName));
|
"clientname", orEmpty(invokerName));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, String> serverVars() {
|
Map<String, String> serverVars() {
|
||||||
return SoundNotifier.vars("servername", model.getServerName());
|
return SoundNotifier.vars("servername", model.getServerName());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1739,15 +1000,17 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
return m.contains("insufficient") || m.contains("permission");
|
return m.contains("insufficient") || m.contains("permission");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the client is in the channel we are in ourselves. */
|
/** Whether the client is in the channel we are in ourselves. Package-private: also
|
||||||
private boolean inOwnChannel(int channelId) {
|
* used by {@link ConnectionEventHandler}. */
|
||||||
|
boolean inOwnChannel(int channelId) {
|
||||||
ClientEntry self = model.getClient(selfClientId);
|
ClientEntry self = model.getClient(selfClientId);
|
||||||
return self != null && self.channelId == channelId;
|
return self != null && self.channelId == channelId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- 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));
|
return safeLong(e.get(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1760,65 +1023,11 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Package-private: also used by {@link ConnectionEventHandler} and {@link ConnectionStatsCollector}. */
|
||||||
* Whether the event actually carries a field. ts3j answers a missing key with an
|
static String orEmpty(String s) {
|
||||||
* 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) {
|
|
||||||
return s == null ? "" : 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) {
|
private static String rootMessage(Throwable t) {
|
||||||
Throwable r = t;
|
Throwable r = t;
|
||||||
while (r.getCause() != null && r.getCause() != r) r = r.getCause();
|
while (r.getCause() != null && r.getCause() != r) r = r.getCause();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
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
|
* 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.
|
* every partial update (someone muting, say) as if it reported every other field too.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user