Report a chosen client version and operating system
Other clients see the version and platform from clientinit in the info panel, and until now that was whatever ts3j sends. The new Client Version tab picks one instead. Servers refuse a version they do not know with "client is modified", so free text is not enough: the list holds genuine version/platform/signature triples from released clients, newest first per platform. The fields stay editable for a version whose signature is known from elsewhere, and a refused connection now says where to change it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
package com.ts3client.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The version, platform and signature a client reports to the server in
|
||||
* {@code clientinit}. Other clients see the version and platform in the info
|
||||
* panel ("3.6.2 on Windows").
|
||||
*
|
||||
* <p>The signature is TeamSpeak's, over the version and platform strings
|
||||
* together, so the three only work as a set. Servers answer "client is modified"
|
||||
* for a version they do not accept — an invented one, or one older than their
|
||||
* configured minimum — which is why {@link #PRESETS} holds genuine triples taken
|
||||
* from released clients, newest first per platform.
|
||||
*
|
||||
* @param label human-readable name, e.g. "3.6.2 on Windows"
|
||||
* @param version {@code client_version}, e.g. "3.6.2 [Build: 1695203293]"
|
||||
* @param platform {@code client_platform}, e.g. "Windows"
|
||||
* @param sign {@code client_version_sign}, base64
|
||||
*/
|
||||
public record ClientVersion(String label, String version, String platform, String sign) {
|
||||
|
||||
/** Platform names the official clients report. */
|
||||
public static final List<String> PLATFORMS =
|
||||
List.of("Windows", "Linux", "OS X", "Android", "iOS");
|
||||
|
||||
/**
|
||||
* Signed triples from released TeamSpeak clients. "3.?.?" is the version the
|
||||
* SDK-based clients report; it is signed for every platform and is what this
|
||||
* client uses by default.
|
||||
*/
|
||||
public static final List<ClientVersion> PRESETS = List.of(
|
||||
new ClientVersion("3.?.? on Windows", "3.?.? [Build: 5680278000]", "Windows",
|
||||
"DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOoBzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg=="),
|
||||
new ClientVersion("3.6.3 on Windows", "3.6.3 [Build: 1701166057]", "Windows",
|
||||
"ML3yE6pV0QGyhc8o7k1bgqDSj+5gZIQFMjBNhWJ6L0FtJwniBq8l1wivx3DzgVVCvyRbzfg8MsdV/rjl5sHNCw=="),
|
||||
new ClientVersion("3.6.2 on Windows", "3.6.2 [Build: 1695203293]", "Windows",
|
||||
"4BdaZpdgUSMCuIs8qcloJPNxNlJ4o7QKnxMCRO60mSOTtJZyKjOrGLAmeAEtLIJjcjmdSpycMbQOIV92K2vXAw=="),
|
||||
new ClientVersion("3.6.1 on Windows", "3.6.1 [Build: 1690872913]", "Windows",
|
||||
"+YmPaYUBL4F9+TgxyFhlT8qIi1Ym+So4NNMcuohla/u4kfJCGabZ1vDF4cIOAghIqWfO+bQ0OkORZrCBdpi2Cw=="),
|
||||
new ClientVersion("3.6.0 on Windows", "3.6.0 [Build: 1686749764]", "Windows",
|
||||
"gfzG6CfwNYeqrjT68s7ahPd24A/XS0P8LLAB7hrE54MnrlO12RiflD2w69VZ7EYlqnd9GAiVzdGJHCmCFWtlAQ=="),
|
||||
|
||||
new ClientVersion("3.?.? on Linux", "3.?.? [Build: 5680278000]", "Linux",
|
||||
"Hjd+N58Gv3ENhoKmGYy2bNRBsNNgm5kpiaQWxOj5HN2DXttG6REjymSwJtpJ8muC2gSwRuZi0R+8Laan5ts5CQ=="),
|
||||
new ClientVersion("3.6.2 on Linux", "3.6.2 [Build: 1695203293]", "Linux",
|
||||
"p4iF1jZ3ZOz9MEkKSZ2bvnFtm9WmUcQy9mAP//erFE4PF1sB6K1CSANrr+3X4B0aZR0u+K2pjnv8kiKsWKQCBQ=="),
|
||||
new ClientVersion("3.6.1 on Linux", "3.6.1 [Build: 1690193193]", "Linux",
|
||||
"UAcE5h5FgPcdChWTtiO816HX8f2qdoDcDEw6crbJ96Yo7rp1Bt+qx/rkKK9bzlVWMhOd4fI7pr++1XaPkEBnAQ=="),
|
||||
new ClientVersion("3.6.0 on Linux", "3.6.0 [Build: 1686749764]", "Linux",
|
||||
"TccRBHwn5LSA0lfpa2B42QmfCOiG+MqqyhlOqy5RcuRQNUGPD0e47lUz+Z/8+Gsdc1TPG4+vYlRl8+v2v+26Aw=="),
|
||||
new ClientVersion("3.5.8 on Linux", "3.5.8 [Build: 1644595825]", "Linux",
|
||||
"CK+cWJQESZgUdw1AFHINMbwiLjFGSiupour0Dfj/j1/xwx4L3nW3G+alYtiQw/wQwlWm873HS6PvXUBarz/tCw=="),
|
||||
|
||||
new ClientVersion("3.?.? on OS X", "3.?.? [Build: 5680278000]", "OS X",
|
||||
"SttEnjoWE8jqIM6BOHSfiZP9DGjW0EP/ajU4bdKqgGMV4aYq/kzwVA9gxbmdIzV4lbaokvXBqrRjfBHrTVh8Cg=="),
|
||||
new ClientVersion("3.6.2 on OS X", "3.6.2 [Build: 1695203293]", "OS X",
|
||||
"SOCkLcsxINIGVc+3OQ5Vrmv2BVNNFA9LX5Jj9tqbIiYv2HU6Z5E8AL5+v5G2LDN0F+U55+uW43ezUoYAr1WuBA=="),
|
||||
new ClientVersion("3.6.1 on OS X", "3.6.1 [Build: 1690193193]", "OS X",
|
||||
"P+KOw42V8K0nN5FbGItCaJzrDuWcRgfE3MRzrtbYAhRMTXHzRxvwLkoLM7+vKTW135NtWZlKbZuDAvxgwhSLBg=="),
|
||||
new ClientVersion("3.6.0 on OS X", "3.6.0 [Build: 1686749764]", "OS X",
|
||||
"g8H7Op8eCrn0wHutuD/SlmUcZPmkSlFUBhvZpSgm9kuQl4dapzKJq2anI5jDwRs/ooBm/wrIlSiWI6HXuOPZDA=="),
|
||||
|
||||
new ClientVersion("3.?.? on Android", "3.?.? [Build: 5680278000]", "Android",
|
||||
"AWb948BY32Z7bpIyoAlQguSmxOGcmjESPceQe1DpW5IZ4+AW1KfTk2VUIYNfUPsxReDJMCtlhVKslzhR2lf0AA=="),
|
||||
new ClientVersion("3.8.7 on Android", "3.8.7 [Build: 1785186501]", "Android",
|
||||
"mbt0WPhXJ+YKJUix5b6t+ZOHTCfvRqhhhrpcS5koM9CUL0BAFQZRG5Di5rJrqipZdk+gEUtpAX9I5nkuL7V9Dw=="),
|
||||
new ClientVersion("3.8.6 on Android", "3.8.6 [Build: 1774892004]", "Android",
|
||||
"Hh0oEZupsq8f0BuUHhMKdkXsMmgZZ03dJL8qAwnlH2JAHuefaAZFS/buiH2MLwgdRkZ81ffYk5YOTfc/bJczDw=="),
|
||||
|
||||
new ClientVersion("3.?.? on iOS", "3.?.? [Build: 5680278000]", "iOS",
|
||||
"XrAf+Buq6Eb0ehEW/niFp06YX+nGGOS0Ke4MoUBzn+cX9q6G5C0A/d5XtgcNMe8r9jJgV/adIYVpsGS3pVlSAA=="),
|
||||
new ClientVersion("3.8.2 on iOS", "3.8.2 [Build: 1764028963]", "iOS",
|
||||
"Bc57xjumaioWkouTRqoS6XbSIXysrPH+gxynXgL4/nYr38l4OocUTpQaMMHevmgk/FZxJWFNEhU3Hpg5+M+MAA=="),
|
||||
new ClientVersion("3.8.1 on iOS", "3.8.1 [Build: 1763403218]", "iOS",
|
||||
"GM/7isPK6LDKTN3Hdelenbr99dnPyWTccLCOCFKK41321XMsdG02putu+NskCLddlDPu2CPYJ4YABRS2pJ88Ag=="));
|
||||
|
||||
/** The triple used when the user has not chosen one; matches the library's built-in. */
|
||||
public static final ClientVersion DEFAULT = PRESETS.get(0);
|
||||
|
||||
/** The preset carrying exactly these three values, or null when the user typed their own. */
|
||||
public static ClientVersion match(String version, String platform, String sign) {
|
||||
return PRESETS.stream()
|
||||
.filter(v -> v.version.equals(version) && v.platform.equals(platform) && v.sign.equals(sign))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,16 @@ public final class Settings {
|
||||
/** Id of the identity used when a server doesn't select one of its own. */
|
||||
public String defaultIdentityId = "";
|
||||
|
||||
// ---- reported client version ----
|
||||
/** Report {@link #clientVersion}/{@link #clientPlatform} instead of the built-in ones. */
|
||||
public boolean customVersion = false;
|
||||
/** Version string other clients see, e.g. "3.5.6 [Build: 1603963200]". */
|
||||
public String clientVersion = ClientVersion.DEFAULT.version();
|
||||
/** Operating system other clients see, e.g. "Windows". */
|
||||
public String clientPlatform = ClientVersion.DEFAULT.platform();
|
||||
/** TeamSpeak's signature over the version and platform; servers may verify it. */
|
||||
public String clientVersionSign = ClientVersion.DEFAULT.sign();
|
||||
|
||||
// ---- audio devices (mixer names; empty = system default) ----
|
||||
public String inputDevice = "";
|
||||
public String outputDevice = "";
|
||||
@@ -152,6 +162,10 @@ public final class Settings {
|
||||
serverPassword = props.getProperty("serverPassword", serverPassword);
|
||||
identityFile = props.getProperty("identityFile", identityFile);
|
||||
defaultIdentityId = props.getProperty("defaultIdentityId", defaultIdentityId);
|
||||
customVersion = parseB(props.getProperty("customVersion"), customVersion);
|
||||
clientVersion = props.getProperty("clientVersion", clientVersion);
|
||||
clientPlatform = props.getProperty("clientPlatform", clientPlatform);
|
||||
clientVersionSign = props.getProperty("clientVersionSign", clientVersionSign);
|
||||
inputDevice = props.getProperty("inputDevice", inputDevice);
|
||||
outputDevice = props.getProperty("outputDevice", outputDevice);
|
||||
inputMode = parseMode(props.getProperty("inputMode"), inputMode);
|
||||
@@ -186,6 +200,10 @@ public final class Settings {
|
||||
props.setProperty("serverPassword", serverPassword);
|
||||
props.setProperty("identityFile", identityFile);
|
||||
props.setProperty("defaultIdentityId", defaultIdentityId);
|
||||
props.setProperty("customVersion", Boolean.toString(customVersion));
|
||||
props.setProperty("clientVersion", clientVersion);
|
||||
props.setProperty("clientPlatform", clientPlatform);
|
||||
props.setProperty("clientVersionSign", clientVersionSign);
|
||||
props.setProperty("inputDevice", inputDevice);
|
||||
props.setProperty("outputDevice", outputDevice);
|
||||
props.setProperty("inputMode", inputMode.name());
|
||||
|
||||
@@ -226,6 +226,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
ui.onError("Network error: " + rootMessage(t));
|
||||
});
|
||||
|
||||
applyClientVersion();
|
||||
applyDefaultChannel(channel, channelPassword);
|
||||
|
||||
ui.onStatus("Connecting to " + address + ":" + port + "…");
|
||||
@@ -256,7 +257,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
applyMicrophoneState();
|
||||
} catch (Exception e) {
|
||||
connected = false;
|
||||
ui.onError("Connection failed: " + rootMessage(e));
|
||||
ui.onError("Connection failed: " + rootMessage(e) + versionHint(e));
|
||||
safeCleanup();
|
||||
// Report the failed attempt as a disconnect too, so the UI leaves the
|
||||
// "connecting" state and the tab becomes reusable.
|
||||
@@ -265,6 +266,38 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "client is modified" is what a server answers when it does not accept the reported
|
||||
* version: too old for its minimum, not a version it knows for that platform, or a
|
||||
* signature that does not belong to the two. Easy to run into after editing them by
|
||||
* hand, and the bare message does not point anywhere.
|
||||
*/
|
||||
private String versionHint(Exception e) {
|
||||
if (!settings.customVersion) return "";
|
||||
String message = rootMessage(e);
|
||||
return message != null && message.contains("client is modified")
|
||||
? " — the server did not accept the custom client version; pick a newer one"
|
||||
+ " of the listed versions in Options."
|
||||
: "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the version and operating system the user configured, which is what
|
||||
* other clients see in the info panel. Each field falls back to the library's
|
||||
* built-in one when left empty; the signature has to match the version and
|
||||
* platform it was issued for, or a server may refuse the connection.
|
||||
*/
|
||||
private void applyClientVersion() {
|
||||
if (!settings.customVersion) return;
|
||||
setIfPresent("client.version_string", settings.clientVersion);
|
||||
setIfPresent("client.version_platform", settings.clientPlatform);
|
||||
setIfPresent("client.version_sign", settings.clientVersionSign);
|
||||
}
|
||||
|
||||
private void setIfPresent(String option, String value) {
|
||||
if (value != null && !value.isBlank()) client.setOption(option, value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the server, as part of {@code clientinit}, to place us in a specific
|
||||
* channel right away instead of the default one. This is the path a real client
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.config.ClientVersion;
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JTextField;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
|
||||
/**
|
||||
* Options page for the version and operating system reported to the server, which
|
||||
* is what other clients see next to the nickname ("3.6.2 on Windows").
|
||||
*
|
||||
* <p>Servers verify TeamSpeak's signature over the version and platform and refuse
|
||||
* the connection ("client is modified") when it does not match, so the page is
|
||||
* built around the signed versions in {@link ClientVersion#PRESETS}. The fields
|
||||
* behind them can still be edited by hand for a version whose signature is known
|
||||
* from somewhere else. Changes take effect on the next connect.
|
||||
*/
|
||||
final class ClientVersionPanel extends JPanel {
|
||||
|
||||
/** Combo entry that leaves the three fields to the user. */
|
||||
private static final String CUSTOM = "Custom…";
|
||||
|
||||
private final Settings settings;
|
||||
|
||||
private final JCheckBox enabled = new JCheckBox("Report a custom version and operating system");
|
||||
private final JComboBox<Object> presetCombo = new JComboBox<>();
|
||||
private final JTextField versionField = new JTextField(24);
|
||||
private final JComboBox<String> platformCombo = new JComboBox<>();
|
||||
private final JTextField signField = new JTextField(24);
|
||||
|
||||
/** Set while a preset is being copied into the fields, so that does not count as an edit. */
|
||||
private boolean filling;
|
||||
|
||||
ClientVersionPanel(Settings settings) {
|
||||
super(new BorderLayout(0, 8));
|
||||
this.settings = settings;
|
||||
|
||||
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
||||
add(buildForm(), BorderLayout.NORTH);
|
||||
|
||||
load();
|
||||
enabled.addActionListener(e -> updateEnabled());
|
||||
updateEnabled();
|
||||
}
|
||||
|
||||
/** Copies the edited version into the settings; the caller saves them. */
|
||||
void apply() {
|
||||
settings.customVersion = enabled.isSelected();
|
||||
settings.clientVersion = versionField.getText().trim();
|
||||
settings.clientPlatform = String.valueOf(platformCombo.getEditor().getItem()).trim();
|
||||
settings.clientVersionSign = signField.getText().trim();
|
||||
}
|
||||
|
||||
private JComponent buildForm() {
|
||||
JPanel p = new JPanel(new GridBagLayout());
|
||||
GridBagConstraints c = new GridBagConstraints();
|
||||
c.insets = new Insets(2, 2, 2, 2);
|
||||
c.anchor = GridBagConstraints.WEST;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
|
||||
presetCombo.addItem(CUSTOM);
|
||||
for (ClientVersion preset : ClientVersion.PRESETS) presetCombo.addItem(preset);
|
||||
presetCombo.setRenderer(new DefaultListCellRenderer() {
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
|
||||
boolean selected, boolean focus) {
|
||||
super.getListCellRendererComponent(list, value, index, selected, focus);
|
||||
setText(value instanceof ClientVersion v ? v.label() : String.valueOf(value));
|
||||
return this;
|
||||
}
|
||||
});
|
||||
presetCombo.setToolTipText("Versions with a signature TeamSpeak issued for them");
|
||||
presetCombo.addActionListener(e -> onPresetSelected());
|
||||
|
||||
platformCombo.setEditable(true);
|
||||
for (String platform : ClientVersion.PLATFORMS) platformCombo.addItem(platform);
|
||||
|
||||
versionField.setToolTipText("Version string, e.g. 3.6.2 [Build: 1695203293]");
|
||||
platformCombo.setToolTipText("Operating system shown to other clients");
|
||||
signField.setToolTipText("TeamSpeak's signature for this version and platform");
|
||||
versionField.getDocument().addDocumentListener(new EditListener());
|
||||
signField.getDocument().addDocumentListener(new EditListener());
|
||||
platformCombo.addActionListener(e -> onFieldEdited());
|
||||
|
||||
int row = 0;
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
p.add(enabled, c);
|
||||
c.gridwidth = 1;
|
||||
|
||||
addRow(p, c, row++, new JLabel("Version:"), presetCombo);
|
||||
addRow(p, c, row++, new JLabel("Version string:"), versionField);
|
||||
addRow(p, c, row++, new JLabel("Operating system:"), platformCombo);
|
||||
addRow(p, c, row++, new JLabel("Signature:"), signField);
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.gridwidth = 2;
|
||||
p.add(new JLabel("<html><body style='width:320px'>The three fields belong together: the "
|
||||
+ "signature is TeamSpeak's, over that version and operating system. A server "
|
||||
+ "answers \"client is modified\" and refuses the connection when it does not "
|
||||
+ "accept the version — made up, or older than the server's minimum — so prefer "
|
||||
+ "a recent one from the list. Takes effect the next time you connect."
|
||||
+ "</body></html>"), c);
|
||||
return p;
|
||||
}
|
||||
|
||||
private void load() {
|
||||
enabled.setSelected(settings.customVersion);
|
||||
ClientVersion preset = ClientVersion.match(
|
||||
settings.clientVersion, settings.clientPlatform, settings.clientVersionSign);
|
||||
fill(preset != null ? preset
|
||||
: new ClientVersion("", settings.clientVersion, settings.clientPlatform, settings.clientVersionSign));
|
||||
presetCombo.setSelectedItem(preset != null ? preset : CUSTOM);
|
||||
}
|
||||
|
||||
private void onPresetSelected() {
|
||||
if (presetCombo.getSelectedItem() instanceof ClientVersion preset) fill(preset);
|
||||
}
|
||||
|
||||
/** A hand-edited field no longer describes the selected preset. */
|
||||
private void onFieldEdited() {
|
||||
if (filling) return;
|
||||
ClientVersion preset = ClientVersion.match(versionField.getText().trim(),
|
||||
String.valueOf(platformCombo.getEditor().getItem()).trim(), signField.getText().trim());
|
||||
presetCombo.setSelectedItem(preset != null ? preset : CUSTOM);
|
||||
}
|
||||
|
||||
private void fill(ClientVersion version) {
|
||||
filling = true;
|
||||
try {
|
||||
versionField.setText(version.version());
|
||||
platformCombo.setSelectedItem(version.platform());
|
||||
platformCombo.getEditor().setItem(version.platform());
|
||||
signField.setText(version.sign());
|
||||
} finally {
|
||||
filling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateEnabled() {
|
||||
boolean on = enabled.isSelected();
|
||||
presetCombo.setEnabled(on);
|
||||
versionField.setEnabled(on);
|
||||
platformCombo.setEnabled(on);
|
||||
signField.setEnabled(on);
|
||||
}
|
||||
|
||||
/** Notices any typing in the version or signature field. */
|
||||
private final class EditListener implements javax.swing.event.DocumentListener {
|
||||
@Override
|
||||
public void insertUpdate(javax.swing.event.DocumentEvent e) {
|
||||
onFieldEdited();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUpdate(javax.swing.event.DocumentEvent e) {
|
||||
onFieldEdited();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changedUpdate(javax.swing.event.DocumentEvent e) {
|
||||
onFieldEdited();
|
||||
}
|
||||
}
|
||||
|
||||
private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) {
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weightx = 0;
|
||||
p.add(label, c);
|
||||
c.gridx = 1;
|
||||
c.weightx = 1;
|
||||
p.add(field, c);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ public final class SettingsDialog extends JDialog {
|
||||
|
||||
private NotificationsPanel notificationsPanel;
|
||||
private IconPackPanel iconPackPanel;
|
||||
private ClientVersionPanel clientVersionPanel;
|
||||
|
||||
private JComboBox<AudioDevices.Device> inputCombo;
|
||||
private JComboBox<AudioDevices.Device> outputCombo;
|
||||
@@ -107,6 +108,8 @@ public final class SettingsDialog extends JDialog {
|
||||
tabs.addTab("Notifications", notificationsPanel);
|
||||
iconPackPanel = new IconPackPanel(settings);
|
||||
tabs.addTab("Design", iconPackPanel);
|
||||
clientVersionPanel = new ClientVersionPanel(settings);
|
||||
tabs.addTab("Client Version", scrollable(clientVersionPanel));
|
||||
|
||||
JPanel buttons = new JPanel(new BorderLayout());
|
||||
JPanel right = new JPanel();
|
||||
@@ -551,6 +554,7 @@ public final class SettingsDialog extends JDialog {
|
||||
settings.music = musicCheck.isSelected();
|
||||
notificationsPanel.apply();
|
||||
iconPackPanel.apply();
|
||||
clientVersionPanel.apply();
|
||||
settings.save();
|
||||
|
||||
if (liveMic != null) {
|
||||
|
||||
Reference in New Issue
Block a user