diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java b/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java index b6a368b..d0f385f 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java @@ -43,6 +43,11 @@ public final class ClientEntry { return type == 1; } + public boolean inServerGroup(int groupId) { + for (int id : serverGroupIds) if (id == groupId) return true; + return false; + } + /** Adds a server group id, if not already present. */ public void addServerGroup(int groupId) { for (int id : serverGroupIds) if (id == groupId) return; diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index d2998ad..e56f2da 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -132,12 +132,6 @@ public final class ServerModel { selfPermissionValues.put(permId, value); } - /** - * The local client's resolved value for a named permission (e.g. - * {@code "i_group_needed_member_add_power"}), or {@code 0} if it isn't known - * yet (the {@code permissionlist} request is still in flight, or the server - * never reported a non-default value for it). - */ /** Whether the server's {@code permissionlist} has arrived, so ids can be named at all. */ public synchronized boolean hasPermissionNames() { return !permissionNames.isEmpty(); @@ -148,6 +142,12 @@ public final class ServerModel { return permissionNames.get(id); } + /** + * The local client's resolved value for a named permission (e.g. + * {@code "i_group_needed_member_add_power"}), or {@code 0} if it isn't known + * yet (the {@code permissionlist} request is still in flight, or the server + * never reported a non-default value for it). + */ public synchronized int selfPermissionValue(String name) { for (Map.Entry entry : permissionNames.entrySet()) { if (entry.getValue().equals(name)) { @@ -158,6 +158,49 @@ public final class ServerModel { return 0; } + // ---- what the local client may do ---- + + /** + * Whether the local client has enough power to assign {@code group}, or to take it + * away when {@code assigned}: the add power always gates it, and the remove power + * additionally gates a group already held. {@code -1} is TS3's "unlimited" sentinel on + * either side: an unlimited local power always passes, and a group that needs + * unlimited power can only be touched by a local client that has it. + */ + public synchronized boolean canToggleGroup(Group group, boolean assigned) { + return hasPower(group.neededMemberAddPower, selfPermissionValue("i_group_needed_member_add_power")) + && (!assigned || hasPower(group.neededMemberRemovePower, + selfPermissionValue("i_group_needed_member_remove_power"))); + } + + private static boolean hasPower(int needed, int own) { + if (own == -1) return true; + if (needed == -1) return false; + return own >= needed; + } + + /** + * Whether a channel shows in the tree at all — it doesn't while we are not subscribed + * to it (e.g. it needs more subscribe power than we have). + */ + public synchronized boolean isChannelVisible(int channelId) { + ChannelNode channel = channels.get(channelId); + return channel != null && channel.subscribed; + } + + /** + * Whether we can expect a join to succeed: the channel must be visible to us, and + * not already full. This can't account for a join-power permission requirement — + * unlike group membership, TS3 doesn't expose a channel's join-power threshold as + * a plain property, only as a permission resolved through channel/channel-group/ + * server-group inheritance, which isn't something a regular client can query. + */ + public synchronized boolean canJoinChannel(int channelId) { + ChannelNode channel = channels.get(channelId); + if (channel == null || !channel.subscribed) return false; + return channel.maxClients < 0 || channel.clients.size() < channel.maxClients; + } + public synchronized String getServerName() { return serverName; } diff --git a/ts3-client/core/src/test/java/com/ts3client/net/ServerModelRulesTest.java b/ts3-client/core/src/test/java/com/ts3client/net/ServerModelRulesTest.java new file mode 100644 index 0000000..255fa27 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/net/ServerModelRulesTest.java @@ -0,0 +1,60 @@ +package com.ts3client.net; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ServerModelRulesTest { + + private final ServerModel model = new ServerModel(); + + private void grant(String permission, int value) { + model.putPermissionName(permission); + model.putSelfPermissionValue(permissionId(permission), value); + } + + private int permissionId(String name) { + for (int id = 0; ; id++) { + if (name.equals(model.permissionName(id))) return id; + } + } + + private static Group group(int addPower, int removePower) { + return new Group(9, "Group", 0, 0, 1, addPower, removePower); + } + + @Test + void groupNeedsEnoughAddPowerAndRemovePowerToTakeAway() { + grant("i_group_needed_member_add_power", 50); + grant("i_group_needed_member_remove_power", 20); + + assertTrue(model.canToggleGroup(group(50, 0), false)); + assertFalse(model.canToggleGroup(group(51, 0), false)); + assertTrue(model.canToggleGroup(group(50, 20), true)); + assertFalse(model.canToggleGroup(group(50, 21), true)); + } + + @Test + void unlimitedPowerIsMinusOneOnEitherSide() { + grant("i_group_needed_member_add_power", 75); + grant("i_group_needed_member_remove_power", -1); + + assertFalse(model.canToggleGroup(group(-1, 0), false), "a group needing unlimited power"); + assertTrue(model.canToggleGroup(group(75, 1000), true), "unlimited remove power"); + } + + @Test + void joiningNeedsAVisibleChannelWithRoom() { + ChannelNode channel = model.putChannel(1, "Full", 0, 0); + model.putClient(5, "Someone", 1); + assertFalse(model.canJoinChannel(1), "not subscribed"); + + channel.subscribed = true; + assertTrue(model.canJoinChannel(1)); + channel.maxClients = 1; + channel.clients.add(model.getClient(5)); + assertFalse(model.canJoinChannel(1), "full"); + assertFalse(model.canJoinChannel(2), "unknown"); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java index 2d588ba..cbe0ce6 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java @@ -67,7 +67,7 @@ final class ClientMenu { menu.addSeparator(); if (!fromTree) { JMenuItem findInTree = new JMenuItem("Find Client in Channel Tree", Icons.of("CHANNEL_SWITCH")); - findInTree.setEnabled(isVisible(client, model)); + findInTree.setEnabled(model.isChannelVisible(client.channelId)); findInTree.addActionListener(a -> actions.findClientInTree(client)); menu.add(findInTree); } @@ -80,7 +80,7 @@ final class ClientMenu { if (!self) { menu.addSeparator(); JMenuItem joinChannel = new JMenuItem("Join Channel of Client", Icons.of("CHANNEL_SWITCH")); - joinChannel.setEnabled(canJoinChannelOf(client, model)); + joinChannel.setEnabled(model.canJoinChannel(client.channelId)); joinChannel.addActionListener(a -> actions.joinChannel(client.channelId)); menu.add(joinChannel); JMenuItem moveHere = new JMenuItem("Move Client to own Channel", Icons.of("MOVE_CLIENT_TO_OWN_CHANNEL")); @@ -114,28 +114,6 @@ final class ClientMenu { menu.add(remove); } - /** - * Whether the client's channel is actually rendered in the tree — it won't be if - * we're not subscribed to it (e.g. it needs more subscribe power than we have). - */ - private static boolean isVisible(ClientEntry client, ServerModel model) { - ChannelNode channel = model.getChannel(client.channelId); - return channel != null && channel.subscribed; - } - - /** - * Whether we can expect a join to succeed: the channel must be visible to us, and - * not already full. This can't account for a join-power permission requirement — - * unlike group membership, TS3 doesn't expose a channel's join-power threshold as - * a plain property, only as a permission resolved through channel/channel-group/ - * server-group inheritance, which isn't something a regular client can query. - */ - private static boolean canJoinChannelOf(ClientEntry client, ServerModel model) { - ChannelNode channel = model.getChannel(client.channelId); - if (channel == null || !channel.subscribed) return false; - return channel.maxClients < 0 || channel.clients.size() < channel.maxClients; - } - private static JMenu buildServerGroupMenu(ClientEntry client, ServerModel model, GroupIcons groupIcons, ServerTreePanel.Actions actions) { JMenu menu = new JMenu("Set Server Groups"); @@ -145,11 +123,9 @@ final class ClientMenu { menu.add(dialog); menu.addSeparator(); - int addPower = model.selfPermissionValue("i_group_needed_member_add_power"); - int removePower = model.selfPermissionValue("i_group_needed_member_remove_power"); for (Group g : model.allServerGroups()) { - boolean assigned = contains(client.serverGroupIds, g.id); - if (!canAssign(g, assigned, addPower, removePower)) continue; + boolean assigned = client.inServerGroup(g.id); + if (!model.canToggleGroup(g, assigned)) continue; JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g)); item.setSelected(assigned); item.addActionListener(a -> actions.setClientServerGroup(client, g, !assigned)); @@ -162,13 +138,11 @@ final class ClientMenu { ServerTreePanel.Actions actions) { JMenu menu = new JMenu("Set Channel Group"); menu.setIcon(Icons.of("PERMISSIONS_CHANNEL_GROUPS")); - int addPower = model.selfPermissionValue("i_group_needed_member_add_power"); - int removePower = model.selfPermissionValue("i_group_needed_member_remove_power"); int defaultGroupId = model.defaultChannelGroupId(); for (Group g : model.allChannelGroups()) { if (g.id == defaultGroupId) continue; boolean assigned = client.channelGroupId == g.id; - if (!canAssign(g, assigned, addPower, removePower)) continue; + if (!model.canToggleGroup(g, assigned)) continue; JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g)); item.setSelected(assigned); item.addActionListener(a -> actions.setClientChannelGroup(client, g)); @@ -176,27 +150,4 @@ final class ClientMenu { } return menu; } - - /** - * Whether the local client has enough power to (un)assign this group: the add - * power always gates the checkbox, and the remove power additionally gates - * unassigning an already-held group. {@code -1} is TS3's "unlimited" sentinel on - * either side: an unlimited local power always passes, and a group that needs - * unlimited power can only be touched by a local client that has it. - */ - static boolean canAssign(Group g, boolean assigned, int addPower, int removePower) { - return hasPower(g.neededMemberAddPower, addPower) - && (!assigned || hasPower(g.neededMemberRemovePower, removePower)); - } - - private static boolean hasPower(int needed, int own) { - if (own == -1) return true; - if (needed == -1) return false; - return own >= needed; - } - - private static boolean contains(int[] ids, int id) { - for (int i : ids) if (i == id) return true; - return false; - } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerGroupsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerGroupsDialog.java index 9f5c7c4..33188d8 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerGroupsDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerGroupsDialog.java @@ -82,14 +82,12 @@ final class ServerGroupsDialog extends JDialog { } header.setText(client.nickname); - int addPower = conn.getModel().selfPermissionValue("i_group_needed_member_add_power"); - int removePower = conn.getModel().selfPermissionValue("i_group_needed_member_remove_power"); List groups = conn.getModel().allServerGroups(); list.removeAll(); for (Group g : groups) { - boolean assigned = contains(client.serverGroupIds, g.id); - boolean allowed = ClientMenu.canAssign(g, assigned, addPower, removePower); + boolean assigned = client.inServerGroup(g.id); + boolean allowed = conn.getModel().canToggleGroup(g, assigned); ImageIcon icon = groupIcons.iconOf(g); JCheckBox box = new JCheckBox(g.name, allowed ? icon : grayscale(icon)); @@ -110,9 +108,4 @@ final class ServerGroupsDialog extends JDialog { return grayscale.computeIfAbsent(icon, i -> new ImageIcon(GrayFilter.createDisabledImage(i.getImage()))); } - - private static boolean contains(int[] ids, int id) { - for (int i : ids) if (i == id) return true; - return false; - } }