diff --git a/README.md b/README.md index fd0f3ac..9772ab6 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ First you'll need to add this project as a dependency. If you're using maven: central bintray - http://jcenter.bintray.com + https://jcenter.bintray.com ``` With gradle: diff --git a/pom.xml b/pom.xml index d905a06..5cd9754 100644 --- a/pom.xml +++ b/pom.xml @@ -1,39 +1,47 @@ - + + 4.0.0 com.jagrosh DiscordIPC - 0.4 + 0.6.0 jar - + - org.json - json - 20230227 + com.google.code.gson + gson + 2.13.2 org.slf4j slf4j-api - 2.0.7 + 2.0.17 com.kohlschutter.junixsocket junixsocket-common - 2.6.2 + 2.10.1 com.kohlschutter.junixsocket junixsocket-native-common - 2.6.2 + 2.10.1 + + + net.lenni0451 + Reflect + 1.6.1 - + org.apache.maven.plugins maven-source-plugin + 3.4.0 attach-sources @@ -46,6 +54,7 @@ org.apache.maven.plugins maven-javadoc-plugin + 3.12.0 attach-javadocs @@ -57,7 +66,7 @@ - + UTF-8 1.8 diff --git a/src/main/java/com/jagrosh/discordipc/IPCClient.java b/src/main/java/com/jagrosh/discordipc/IPCClient.java index a905035..4cf6414 100644 --- a/src/main/java/com/jagrosh/discordipc/IPCClient.java +++ b/src/main/java/com/jagrosh/discordipc/IPCClient.java @@ -13,15 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package com.jagrosh.discordipc; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; import com.jagrosh.discordipc.entities.*; import com.jagrosh.discordipc.entities.Packet.OpCode; import com.jagrosh.discordipc.entities.pipe.Pipe; import com.jagrosh.discordipc.entities.pipe.PipeStatus; import com.jagrosh.discordipc.exceptions.NoDiscordClientException; -import org.json.JSONException; -import org.json.JSONObject; +import com.jagrosh.discordipc.impl.Backoff; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,18 +34,18 @@ /** * Represents a Discord IPC Client that can send and receive - * Rich Presence data.

- * + * Rich Presence data. + *

* The ID provided should be the client ID of the particular * application providing Rich Presence, which can be found - * here.

- * - * When initially created using {@link #IPCClient(long)} the client will + * here. + *

+ * When initially created using {@link #IPCClient(long, boolean, String)} the client will * be inactive awaiting a call to {@link #connect(DiscordBuild...)}.
* After the call, this client can send and receive Rich Presence data * to and from discord via {@link #sendRichPresence(RichPresence)} and - * {@link #setListener(IPCListener)} respectively.

- * + * {@link #setListener(IPCListener)} respectively. + *

* Please be mindful that the client created is initially unconnected, * and calling any methods that exchange data between this client and * Discord before a call to {@link #connect(DiscordBuild...)} will cause @@ -53,47 +55,300 @@ * * @author John Grosh (john.a.grosh@gmail.com) */ -public final class IPCClient implements Closeable -{ +public final class IPCClient implements Closeable { private static final Logger LOGGER = LoggerFactory.getLogger(IPCClient.class); + private final Backoff RECONNECT_TIME_MS = new Backoff(500, 60 * 1000); private final long clientId; - private final HashMap callbacks = new HashMap<>(); + private final boolean autoRegister; + private final HashMap callbacks = new HashMap<>(); + private final String applicationId, optionalSteamId; private volatile Pipe pipe; + private Logger forcedLogger = null; private IPCListener listener = null; private Thread readThread = null; - + private String encoding = "UTF-8"; + private long nextDelay = 0L; + private boolean debugMode; + private boolean verboseLogging; + /** * Constructs a new IPCClient using the provided {@code clientId}.
* This is initially unconnected to Discord. * - * @param clientId The Rich Presence application's client ID, which can be found - * here + * @param clientId The Rich Presence application's client ID, which can be found + * here + * @param debugMode Whether Debug Logging should be shown for this client + * @param verboseLogging Whether excess/deeper-rooted logging should be shown + * @param autoRegister Whether to register as an application with discord + * @param applicationId The application id to register with, usually the client id in string form + * @param optionalSteamId The steam id to register with, registers as a steam game if present */ - public IPCClient(long clientId) - { + public IPCClient(long clientId, boolean debugMode, boolean verboseLogging, boolean autoRegister, String applicationId, String optionalSteamId) { this.clientId = clientId; + this.debugMode = debugMode; + this.verboseLogging = verboseLogging; + this.applicationId = applicationId; + this.autoRegister = autoRegister; + this.optionalSteamId = optionalSteamId; } - + /** - * Sets this IPCClient's {@link IPCListener} to handle received events.

+ * Constructs a new IPCClient using the provided {@code clientId}.
+ * This is initially unconnected to Discord. * - * A single IPCClient can only have one of these set at any given time.
- * Setting this {@code null} will remove the currently active one.

+ * @param clientId The Rich Presence application's client ID, which can be found + * here + * @param debugMode Whether Debug Logging should be shown for this client + * @param verboseLogging Whether excess/deeper-rooted logging should be shown + * @param autoRegister Whether to register as an application with discord + * @param applicationId The application id to register with, usually the client id in string form + */ + public IPCClient(long clientId, boolean debugMode, boolean verboseLogging, boolean autoRegister, String applicationId) { + this(clientId, debugMode, verboseLogging, autoRegister, applicationId, null); + } + + /** + * Constructs a new IPCClient using the provided {@code clientId}.
+ * This is initially unconnected to Discord. + * + * @param clientId The Rich Presence application's client ID, which can be found + * here + * @param debugMode Whether Debug Logging should be shown for this client + * @param verboseLogging Whether excess/deeper-rooted logging should be shown + */ + public IPCClient(long clientId, boolean debugMode, boolean verboseLogging) { + this(clientId, debugMode, verboseLogging, false, null); + } + + /** + * Constructs a new IPCClient using the provided {@code clientId}.
+ * This is initially unconnected to Discord. + * + * @param clientId The Rich Presence application's client ID, which can be found + * here + * @param debugMode Whether Debug Logging should be shown for this client + * @param autoRegister Whether to register as an application with discord + * @param applicationId The application id to register with, usually the client id in string form + * @param optionalSteamId The steam id to register with, registers as a steam game if present + */ + public IPCClient(long clientId, boolean debugMode, boolean autoRegister, String applicationId, String optionalSteamId) { + this(clientId, debugMode, false, autoRegister, applicationId, optionalSteamId); + } + + /** + * Constructs a new IPCClient using the provided {@code clientId}.
+ * This is initially unconnected to Discord. + * + * @param clientId The Rich Presence application's client ID, which can be found + * here + * @param debugMode Whether Debug Logging should be shown for this client + * @param autoRegister Whether to register as an application with discord + * @param applicationId The application id to register with, usually the client id in string form + */ + public IPCClient(long clientId, boolean debugMode, boolean autoRegister, String applicationId) { + this(clientId, debugMode, autoRegister, applicationId, null); + } + + /** + * Constructs a new IPCClient using the provided {@code clientId}.
+ * This is initially unconnected to Discord. + * + * @param clientId The Rich Presence application's client ID, which can be found + * here + * @param debugMode Whether Debug Logging should be shown for this client + */ + public IPCClient(long clientId, boolean debugMode) { + this(clientId, debugMode, false, null); + } + + /** + * Constructs a new IPCClient using the provided {@code clientId}.
+ * This is initially unconnected to Discord. + * + * @param clientId The Rich Presence application's client ID, which can be found + * here + * @param autoRegister Whether to register as an application with discord + * @param applicationId The application id to register with, usually the client id in string form + * @param optionalSteamId The steam id to register with, registers as a steam game if present + */ + public IPCClient(long clientId, boolean autoRegister, String applicationId, String optionalSteamId) { + this(clientId, false, autoRegister, applicationId, optionalSteamId); + } + + /** + * Constructs a new IPCClient using the provided {@code clientId}.
+ * This is initially unconnected to Discord. + * + * @param clientId The Rich Presence application's client ID, which can be found + * here + * @param autoRegister Whether to register as an application with discord + * @param applicationId The application id to register with, usually the client id in string form + */ + public IPCClient(long clientId, boolean autoRegister, String applicationId) { + this(clientId, autoRegister, applicationId, null); + } + + /** + * Constructs a new IPCClient using the provided {@code clientId}.
+ * This is initially unconnected to Discord. + * + * @param clientId The Rich Presence application's client ID, which can be found + * here + */ + public IPCClient(long clientId) { + this(clientId, false, null); + } + + /** + * Finds the current process ID. + * + * @return The current process ID. + */ + private static int getPID() { + String pr = ManagementFactory.getRuntimeMXBean().getName(); + return Integer.parseInt(pr.substring(0, pr.indexOf('@'))); + } + + /** + * Retrieves the current logger that should be used + * + * @param instance The logger instance + * @return the current logger to use + */ + public Logger getCurrentLogger(final Logger instance) { + return forcedLogger != null ? forcedLogger : instance; + } + + /** + * Sets the current logger that should be used * + * @param forcedLogger The logger instance to be used + */ + public void setForcedLogger(Logger forcedLogger) { + this.forcedLogger = forcedLogger; + } + + /** + * Sets this IPCClient's {@link IPCListener} to handle received events. + *

+ * A single IPCClient can only have one of these set at any given time.
+ * Setting this {@code null} will remove the currently active one. + *

* This can be set safely before a call to {@link #connect(DiscordBuild...)} * is made. * * @param listener The {@link IPCListener} to set for this IPCClient. - * * @see IPCListener */ - public void setListener(IPCListener listener) - { + public void setListener(IPCListener listener) { this.listener = listener; if (pipe != null) pipe.setListener(listener); } - + + /** + * Gets the application id associated with this IPCClient + *

+ * This must be set upon initialization and is a required variable + * + * @return applicationId + */ + public String getApplicationId() { + return applicationId; + } + + /** + * Gets the steam id associated with this IPCClient, if any + *

+ * This must be set upon initialization and is an optional variable
+ * If set and autoRegister is true, then this client will register as a steam game + * + * @return optionalSteamId + */ + public String getOptionalSteamId() { + return optionalSteamId; + } + + /** + * Gets whether the client will register a run command with discord + * + * @return autoRegister + */ + public boolean isAutoRegister() { + return autoRegister; + } + + /** + * Gets encoding to send packets in.

+ * Default: UTF-8 + * + * @return encoding + */ + public String getEncoding() { + return this.encoding; + } + + /** + * Sets the encoding to send packets in. + *

+ * This can be set safely before a call to {@link #connect(DiscordBuild...)} + * is made. + *

+ * Default: UTF-8 + * + * @param encoding for this IPCClient. + */ + public void setEncoding(final String encoding) { + this.encoding = encoding; + } + + /** + * Gets the client ID associated with this IPCClient + * + * @return the client id + */ + public long getClientID() { + return this.clientId; + } + + /** + * Gets whether this IPCClient is in Debug Mode + * Default: False + * + * @return The Debug Mode Status + */ + public boolean isDebugMode() { + return debugMode; + } + + /** + * Sets whether this IPCClient is in Debug Mode + * + * @param debugMode The Debug Mode Status + */ + public void setDebugMode(boolean debugMode) { + this.debugMode = debugMode; + } + + /** + * Gets whether this IPCClient will show verbose logging + * Default: False + * + * @return The Verbose Logging Status + */ + public boolean isVerboseLogging() { + return verboseLogging; + } + + /** + * Sets whether this IPCClient will show verbose logging + * + * @param verboseLogging The Verbose Mode Status + */ + public void setVerboseLogging(boolean verboseLogging) { + this.verboseLogging = verboseLogging; + } + /** * Opens the connection between the IPCClient and Discord.

* @@ -101,75 +356,131 @@ public void setListener(IPCListener listener) * IPCClient and Discord. * * @param preferredOrder the priority order of client builds to connect to - * - * @throws IllegalStateException - * There is an open connection on this IPCClient. - * @throws NoDiscordClientException - * No client of the provided {@link DiscordBuild build type}(s) was found. + * @throws IllegalStateException There is an open connection on this IPCClient. + * @throws NoDiscordClientException No client of the provided {@link DiscordBuild build type}(s) was found. */ - public void connect(DiscordBuild... preferredOrder) throws NoDiscordClientException - { + @SuppressWarnings("BusyWait") + public void connect(DiscordBuild... preferredOrder) throws NoDiscordClientException { checkConnected(false); + long timeToConnect; + while ((timeToConnect = nextDelay - System.currentTimeMillis()) > 0) { + if (debugMode) { + getCurrentLogger(LOGGER).info("[DEBUG] Attempting connection in: " + timeToConnect + "ms"); + } + try { + Thread.sleep(timeToConnect); + } catch (InterruptedException ignored) { + } + } callbacks.clear(); pipe = null; - pipe = Pipe.openPipe(this, clientId, callbacks, preferredOrder); + try { + pipe = Pipe.openPipe(this, clientId, callbacks, preferredOrder); + } catch (Exception ex) { + updateReconnectTime(); + throw ex; + } - LOGGER.debug("Client is now connected and ready!"); - if(listener != null) + if (isAutoRegister()) { + try { + if (optionalSteamId != null && !optionalSteamId.isEmpty()) + this.registerSteamGame(getApplicationId(), optionalSteamId); + else + this.registerApp(getApplicationId(), null); + } catch (Throwable ex) { + if (debugMode) { + getCurrentLogger(LOGGER).error("Unable to register application", ex); + } else { + getCurrentLogger(LOGGER).error("Unable to register application, enable debug mode for trace..."); + } + } + } + + if (debugMode) { + getCurrentLogger(LOGGER).info("[DEBUG] Client is now connected and ready!"); + } + + if (listener != null) { listener.onReady(this); + pipe.setListener(listener); + } startReading(); } - + /** - * Sends a {@link RichPresence} to the Discord client.

- * + * Sends a {@link RichPresence} to the Discord client. + *

* This is where the IPCClient will officially display - * a Rich Presence in the Discord client.

- * + * a Rich Presence in the Discord client. + *

* Sending this again will overwrite the last provided * {@link RichPresence}. * * @param presence The {@link RichPresence} to send. - * - * @throws IllegalStateException - * If a connection was not made prior to invoking - * this method. - * + * @throws IllegalStateException If a connection was not made prior to invoking + * this method. * @see RichPresence */ - public void sendRichPresence(RichPresence presence) - { + public void sendRichPresence(RichPresence presence) { sendRichPresence(presence, null); } - + /** - * Sends a {@link RichPresence} to the Discord client.

- * + * Sends a {@link RichPresence} to the Discord client. + *

* This is where the IPCClient will officially display - * a Rich Presence in the Discord client.

- * + * a Rich Presence in the Discord client. + *

* Sending this again will overwrite the last provided * {@link RichPresence}. * * @param presence The {@link RichPresence} to send. * @param callback A {@link Callback} to handle success or error - * - * @throws IllegalStateException - * If a connection was not made prior to invoking - * this method. - * + * @throws IllegalStateException If a connection was not made prior to invoking + * this method. * @see RichPresence */ - public void sendRichPresence(RichPresence presence, Callback callback) - { + public void sendRichPresence(RichPresence presence, Callback callback) { checkConnected(true); - LOGGER.debug("Sending RichPresence to discord: "+(presence == null ? null : presence.toJson().toString())); - pipe.send(OpCode.FRAME, new JSONObject() - .put("cmd","SET_ACTIVITY") - .put("args", new JSONObject() - .put("pid",getPID()) - .put("activity",presence == null ? null : presence.toJson())), callback); + + if (debugMode) { + getCurrentLogger(LOGGER).info("[DEBUG] Sending RichPresence to discord: " + presence.toDecodedJson(encoding)); + } + + // Setup and Send JsonObject Data Representing an RPC Update + JsonObject finalObject = new JsonObject(), + args = new JsonObject(); + + finalObject.addProperty("cmd", "SET_ACTIVITY"); + + args.addProperty("pid", getPID()); + args.add("activity", presence.toJson()); + + finalObject.add("args", args); + pipe.send(OpCode.FRAME, finalObject, callback); + } + + /** + * Manually register a steam game + * + * @param applicationId Application ID + * @param optionalSteamId Application Steam ID + */ + public void registerSteamGame(String applicationId, String optionalSteamId) { + if (this.pipe != null) + this.pipe.registerSteamGame(applicationId, optionalSteamId); + } + + /** + * Manually register an application + * + * @param applicationId Application ID + * @param command Command to run the application + */ + public void registerApp(String applicationId, String command) { + if (this.pipe != null) + this.pipe.registerApp(applicationId, command); } /** @@ -181,16 +492,13 @@ public void sendRichPresence(RichPresence presence, Callback callback) * and creating a new one. * * @param sub The event {@link Event} to add. - * - * @throws IllegalStateException - * If a connection was not made prior to invoking - * this method. + * @throws IllegalStateException If a connection was not made prior to invoking + * this method. */ - public void subscribe(Event sub) - { + public void subscribe(Event sub) { subscribe(sub, null); } - + /** * Adds an event {@link Event} to this IPCClient.
* If the provided {@link Event} is added more than once, @@ -199,22 +507,62 @@ public void subscribe(Event sub) * other than {@link #close() closing} the connection * and creating a new one. * - * @param sub The event {@link Event} to add. + * @param sub The event {@link Event} to add. * @param callback The {@link Callback} to handle success or failure + * @throws IllegalStateException If a connection was not made prior to invoking + * this method. + */ + public void subscribe(Event sub, Callback callback) { + checkConnected(true); + if (!sub.isSubscribable()) + throw new IllegalStateException("Cannot subscribe to " + sub + " event!"); + + if (debugMode) { + getCurrentLogger(LOGGER).info(String.format("[DEBUG] Subscribing to Event: %s", sub.name())); + } + + JsonObject pipeData = new JsonObject(); + pipeData.addProperty("cmd", "SUBSCRIBE"); + pipeData.addProperty("evt", sub.name()); + + pipe.send(OpCode.FRAME, pipeData, callback); + } + + /** + * Responds to a {@link Event#ACTIVITY_JOIN_REQUEST} from a requester {@link User}. * - * @throws IllegalStateException - * If a connection was not made prior to invoking - * this method. + * @param user The {@link User} to respond to + * @param approvalMode The {@link ApprovalMode} to respond to the requester with + * @param callback The {@link Callback} to handle success or failure */ - public void subscribe(Event sub, Callback callback) - { + public void respondToJoinRequest(User user, ApprovalMode approvalMode, Callback callback) { checkConnected(true); - if(!sub.isSubscribable()) - throw new IllegalStateException("Cannot subscribe to "+sub+" event!"); - LOGGER.debug(String.format("Subscribing to Event: %s", sub.name())); - pipe.send(OpCode.FRAME, new JSONObject() - .put("cmd", "SUBSCRIBE") - .put("evt", sub.name()), callback); + + if (user != null) { + if (debugMode) { + getCurrentLogger(LOGGER).info(String.format("[DEBUG] Sending response to %s as %s", user.getName(), approvalMode.name())); + } + + JsonObject pipeData = new JsonObject(); + pipeData.addProperty("cmd", approvalMode == ApprovalMode.ACCEPT ? "SEND_ACTIVITY_JOIN_INVITE" : "CLOSE_ACTIVITY_JOIN_REQUEST"); + + JsonObject args = new JsonObject(); + args.addProperty("user_id", user.getId()); + + pipeData.add("args", args); + + pipe.send(OpCode.FRAME, pipeData, callback); + } + } + + /** + * Responds to a {@link Event#ACTIVITY_JOIN_REQUEST} from a requester {@link User}. + * + * @param user The {@link User} to respond to + * @param approvalMode The {@link ApprovalMode} to respond to the requester with + */ + public void respondToJoinRequest(User user, ApprovalMode approvalMode) { + respondToJoinRequest(user, approvalMode, null); } /** @@ -222,8 +570,7 @@ public void subscribe(Event sub, Callback callback) * * @return The IPCClient's current {@link PipeStatus}. */ - public PipeStatus getStatus() - { + public PipeStatus getStatus() { if (pipe == null) return PipeStatus.UNINITIALIZED; return pipe.getStatus(); @@ -233,95 +580,61 @@ public PipeStatus getStatus() * Attempts to close an open connection to Discord.
* This can be reopened with another call to {@link #connect(DiscordBuild...)}. * - * @throws IllegalStateException - * If a connection was not made prior to invoking - * this method. + * @throws IllegalStateException If a connection was not made prior to invoking + * this method. */ @Override - public void close() - { + public void close() { checkConnected(true); try { pipe.close(); } catch (IOException e) { - LOGGER.debug("Failed to close pipe", e); + if (debugMode) { + getCurrentLogger(LOGGER).info(String.format("[DEBUG] Failed to close pipe: %s", e)); + } } } /** - * Gets the IPCClient's {@link DiscordBuild}.

- * + * Gets the IPCClient's {@link DiscordBuild}. + *

* This is always the first specified DiscordBuild when * making a call to {@link #connect(DiscordBuild...)}, * or the first one found if none or {@link DiscordBuild#ANY} - * is specified.

- * + * is specified. + *

* Note that specifying ANY doesn't mean that this will return * ANY. In fact this method should never return the * value ANY. * * @return The {@link DiscordBuild} of this IPCClient, or null if not connected. */ - public DiscordBuild getDiscordBuild() - { + public DiscordBuild getDiscordBuild() { if (pipe == null) return null; return pipe.getDiscordBuild(); } /** - * Constants representing events that can be subscribed to - * using {@link #subscribe(Event)}.

+ * Gets the IPCClient's current {@link User} attached to the target {@link DiscordBuild}. + *

+ * This is always the User Data attached to the DiscordBuild found when + * making a call to {@link #connect(DiscordBuild...)} + *

+ * Note that this value should NOT return null under any circumstances. * - * Each event corresponds to a different function as a - * component of the Rich Presence.
- * A full breakdown of each is available - * here. + * @return The current {@link User} of this IPCClient from the target {@link DiscordBuild}, or null if not found. */ - public enum Event - { - NULL(false), // used for confirmation - READY(false), - ERROR(false), - ACTIVITY_JOIN(true), - ACTIVITY_SPECTATE(true), - ACTIVITY_JOIN_REQUEST(true), - /** - * A backup key, only important if the - * IPCClient receives an unknown event - * type in a JSON payload. - */ - UNKNOWN(false); - - private final boolean subscribable; - - Event(boolean subscribable) - { - this.subscribable = subscribable; - } - - public boolean isSubscribable() - { - return subscribable; - } - - static Event of(String str) - { - if(str==null) - return NULL; - for(Event s : Event.values()) - { - if(s != UNKNOWN && s.name().equalsIgnoreCase(str)) - return s; - } - return UNKNOWN; - } + public User getCurrentUser() { + if (pipe == null) return null; + + return pipe.getCurrentUser(); } // Private methods - + /** * Makes sure that the client is connected (or not) depending on if it should * for the current state. @@ -329,122 +642,185 @@ static Event of(String str) * @param connected Whether to check in the context of the IPCClient being * connected or not. */ - private void checkConnected(boolean connected) - { - if(connected && getStatus() != PipeStatus.CONNECTED) + private void checkConnected(boolean connected) { + if (connected && getStatus() != PipeStatus.CONNECTED) throw new IllegalStateException(String.format("IPCClient (ID: %d) is not connected!", clientId)); - if(!connected && getStatus() == PipeStatus.CONNECTED) + if (!connected && getStatus() == PipeStatus.CONNECTED) throw new IllegalStateException(String.format("IPCClient (ID: %d) is already connected!", clientId)); } - + /** * Initializes this IPCClient's {@link IPCClient#readThread readThread} * and calls the first {@link Pipe#read()}. */ - private void startReading() - { - readThread = new Thread(() -> { - try - { - Packet p; - while((p = pipe.read()).getOp() != OpCode.CLOSE) - { - JSONObject json = p.getJson(); - Event event = Event.of(json.optString("evt", null)); - String nonce = json.optString("nonce", null); - switch(event) - { + private void startReading() { + final IPCClient localInstance = this; + + readThread = new Thread(() -> IPCClient.this.readPipe(localInstance), "IPCClient-Reader"); + readThread.setDaemon(true); + + if (debugMode) { + getCurrentLogger(LOGGER).info("[DEBUG] Starting IPCClient reading thread!"); + } + readThread.start(); + } + + /** + * Call the first {@link Pipe#read()} via try-catch + * + * @param instance The {@link IPCClient} instance + */ + private void readPipe(final IPCClient instance) { + try { + Packet p; + while ((p = pipe.read()).getOp() != OpCode.CLOSE) { + JsonObject json = p.getJson(); + + if (json != null) { + Event event = Event.of(json.has("evt") && !json.get("evt").isJsonNull() ? json.getAsJsonPrimitive("evt").getAsString() : null); + String nonce = json.has("nonce") && !json.get("nonce").isJsonNull() ? json.getAsJsonPrimitive("nonce").getAsString() : null; + + switch (event) { case NULL: - if(nonce != null && callbacks.containsKey(nonce)) + if (nonce != null && callbacks.containsKey(nonce)) callbacks.remove(nonce).succeed(p); break; - + case ERROR: - if(nonce != null && callbacks.containsKey(nonce)) - callbacks.remove(nonce).fail(json.getJSONObject("data").optString("message", null)); + if (nonce != null && callbacks.containsKey(nonce)) + callbacks.remove(nonce).fail(json.has("data") && json.getAsJsonObject("data").has("message") ? json.getAsJsonObject("data").getAsJsonObject("message").getAsString() : null); break; - + case ACTIVITY_JOIN: - LOGGER.debug("Reading thread received a 'join' event."); + if (debugMode) { + getCurrentLogger(LOGGER).info("[DEBUG] Reading thread received a 'join' event."); + } break; - + case ACTIVITY_SPECTATE: - LOGGER.debug("Reading thread received a 'spectate' event."); + if (debugMode) { + getCurrentLogger(LOGGER).info("[DEBUG] Reading thread received a 'spectate' event."); + } break; - + case ACTIVITY_JOIN_REQUEST: - LOGGER.debug("Reading thread received a 'join request' event."); + if (debugMode) { + getCurrentLogger(LOGGER).info("[DEBUG] Reading thread received a 'join request' event."); + } break; - + case UNKNOWN: - LOGGER.debug("Reading thread encountered an event with an unknown type: " + - json.getString("evt")); + if (debugMode) { + getCurrentLogger(LOGGER).info("[DEBUG] Reading thread encountered an event with an unknown type: " + + json.getAsJsonPrimitive("evt").getAsString()); + } + break; + default: break; } - if(listener != null && json.has("cmd") && json.getString("cmd").equals("DISPATCH")) - { - try - { - JSONObject data = json.getJSONObject("data"); - switch(Event.of(json.getString("evt"))) - { + + if (listener != null && json.has("cmd") && json.getAsJsonPrimitive("cmd").getAsString().equals("DISPATCH")) { + try { + JsonObject data = json.getAsJsonObject("data"); + switch (Event.of(json.getAsJsonPrimitive("evt").getAsString())) { case ACTIVITY_JOIN: - listener.onActivityJoin(this, data.getString("secret")); + listener.onActivityJoin(instance, data.getAsJsonPrimitive("secret").getAsString()); break; - + case ACTIVITY_SPECTATE: - listener.onActivitySpectate(this, data.getString("secret")); + listener.onActivitySpectate(instance, data.getAsJsonPrimitive("secret").getAsString()); break; - + case ACTIVITY_JOIN_REQUEST: - JSONObject u = data.getJSONObject("user"); - User user = new User( - u.getString("username"), - u.getString("discriminator"), - Long.parseLong(u.getString("id")), - u.optString("avatar", null) + final JsonObject u = data.getAsJsonObject("user"); + final User user = new User( + u.getAsJsonPrimitive("username").getAsString(), + u.has("global_name") && u.get("global_name").isJsonPrimitive() ? u.getAsJsonPrimitive("global_name").getAsString() : null, + u.has("discriminator") && u.get("discriminator").isJsonPrimitive() ? u.getAsJsonPrimitive("discriminator").getAsString() : "0", + Long.parseLong(u.getAsJsonPrimitive("id").getAsString()), + u.has("avatar") && u.get("avatar").isJsonPrimitive() ? u.getAsJsonPrimitive("avatar").getAsString() : null ); - listener.onActivityJoinRequest(this, data.optString("secret", null), user); + listener.onActivityJoinRequest(instance, data.has("secret") ? data.getAsJsonObject("secret").getAsString() : null, user); + break; + default: break; } - } - catch(Exception e) - { - LOGGER.error("Exception when handling event: ", e); + } catch (Exception e) { + getCurrentLogger(LOGGER).error(String.format("Exception when handling event: %s", e)); } } } - pipe.setStatus(PipeStatus.DISCONNECTED); - if(listener != null) - listener.onClose(this, p.getJson()); } - catch(IOException | JSONException ex) - { - if(ex instanceof IOException) - LOGGER.error("Reading thread encountered an IOException", ex); - else - LOGGER.error("Reading thread encountered an JSONException", ex); + pipe.setStatus(PipeStatus.DISCONNECTED); + if (listener != null) + listener.onClose(instance, p.getJson()); + } catch (IOException | JsonParseException ex) { + getCurrentLogger(LOGGER).error(String.format("Reading thread encountered an Exception: %s", ex)); - pipe.setStatus(PipeStatus.DISCONNECTED); - if(listener != null) - listener.onDisconnect(this, ex); + pipe.setStatus(PipeStatus.DISCONNECTED); + if (listener != null) { + RECONNECT_TIME_MS.reset(); + updateReconnectTime(); + listener.onDisconnect(instance, ex); } - }); + } + } - LOGGER.debug("Starting IPCClient reading thread!"); - readThread.start(); + /** + * Sets the next delay before re-attempting connection. + */ + private void updateReconnectTime() { + nextDelay = System.currentTimeMillis() + RECONNECT_TIME_MS.nextDelay(); + } + + /** + * Constants representing a Response to an Ask to Join or Spectate Request + */ + public enum ApprovalMode { + ACCEPT, DENY } - - // Private static methods - + /** - * Finds the current process ID. - * - * @return The current process ID. + * Constants representing events that can be subscribed to + * using {@link #subscribe(Event)}. + *

+ * Each event corresponds to a different function as a + * component of the Rich Presence.
+ * A full breakdown of each is available + * here. */ - private static int getPID() - { - String pr = ManagementFactory.getRuntimeMXBean().getName(); - return Integer.parseInt(pr.substring(0,pr.indexOf('@'))); + public enum Event { + NULL(false), // used for confirmation + READY(false), + ERROR(false), + ACTIVITY_JOIN(true), + ACTIVITY_SPECTATE(true), + ACTIVITY_JOIN_REQUEST(true), + /** + * A backup key, only important if the + * IPCClient receives an unknown event + * type in a JSON payload. + */ + UNKNOWN(false); + + private final boolean subscribable; + + Event(boolean subscribable) { + this.subscribable = subscribable; + } + + static Event of(String str) { + if (str == null) + return NULL; + for (Event s : Event.values()) { + if (s != UNKNOWN && s.name().equalsIgnoreCase(str)) + return s; + } + return UNKNOWN; + } + + public boolean isSubscribable() { + return subscribable; + } } } diff --git a/src/main/java/com/jagrosh/discordipc/IPCListener.java b/src/main/java/com/jagrosh/discordipc/IPCListener.java index 7496faf..5b50677 100644 --- a/src/main/java/com/jagrosh/discordipc/IPCListener.java +++ b/src/main/java/com/jagrosh/discordipc/IPCListener.java @@ -13,28 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package com.jagrosh.discordipc; +import com.google.gson.JsonObject; import com.jagrosh.discordipc.entities.Packet; import com.jagrosh.discordipc.entities.User; -import org.json.JSONObject; /** - * An implementable listener used to handle events caught by an {@link IPCClient}.

- * + * An implementable listener used to handle events caught by an {@link IPCClient}. + *

* Can be attached to an IPCClient using {@link IPCClient#setListener(IPCListener)}. * * @author John Grosh (john.a.grosh@gmail.com) */ -public interface IPCListener -{ +public interface IPCListener { /** * Fired whenever an {@link IPCClient} sends a {@link Packet} to Discord. * * @param client The IPCClient sending the Packet. * @param packet The Packet being sent. */ - default void onPacketSent(IPCClient client, Packet packet) {} + void onPacketSent(IPCClient client, Packet packet); /** * Fired whenever an {@link IPCClient} receives a {@link Packet} to Discord. @@ -42,7 +42,7 @@ default void onPacketSent(IPCClient client, Packet packet) {} * @param client The IPCClient receiving the Packet. * @param packet The Packet being received. */ - default void onPacketReceived(IPCClient client, Packet packet) {} + void onPacketReceived(IPCClient client, Packet packet); /** * Fired whenever a RichPresence activity informs us that @@ -51,7 +51,7 @@ default void onPacketReceived(IPCClient client, Packet packet) {} * @param client The IPCClient receiving the event. * @param secret The secret of the event, determined by the implementation and specified by the user. */ - default void onActivityJoin(IPCClient client, String secret) {} + void onActivityJoin(IPCClient client, String secret); /** * Fired whenever a RichPresence activity informs us that @@ -60,42 +60,42 @@ default void onActivityJoin(IPCClient client, String secret) {} * @param client The IPCClient receiving the event. * @param secret The secret of the event, determined by the implementation and specified by the user. */ - default void onActivitySpectate(IPCClient client, String secret) {} + void onActivitySpectate(IPCClient client, String secret); /** * Fired whenever a RichPresence activity informs us that - * a user has clicked a "ask to join" button.

- * + * a user has clicked an "ask to join" button. + *

* As opposed to {@link #onActivityJoin(IPCClient, String)}, * this also provides packaged {@link User} data. * * @param client The IPCClient receiving the event. * @param secret The secret of the event, determined by the implementation and specified by the user. - * @param user The user who clicked the clicked the event, containing data on the account. + * @param user The user who clicked the event, containing data on the account. */ - default void onActivityJoinRequest(IPCClient client, String secret, User user) {} + void onActivityJoinRequest(IPCClient client, String secret, User user); /** * Fired whenever an {@link IPCClient} is ready and connected to Discord. * * @param client The now ready IPCClient. */ - default void onReady(IPCClient client) {} + void onReady(IPCClient client); /** * Fired whenever an {@link IPCClient} has closed. * * @param client The now closed IPCClient. - * @param json A {@link JSONObject} with close data. + * @param json A {@link JsonObject} with close data. */ - default void onClose(IPCClient client, JSONObject json) {} + void onClose(IPCClient client, JsonObject json); /** * Fired whenever an {@link IPCClient} has disconnected, * either due to bad data or an exception. * * @param client The now closed IPCClient. - * @param t A {@link Throwable} responsible for the disconnection. + * @param t A {@link Throwable} responsible for the disconnection. */ - default void onDisconnect(IPCClient client, Throwable t) {} + void onDisconnect(IPCClient client, Throwable t); } diff --git a/src/main/java/com/jagrosh/discordipc/entities/ActivityType.java b/src/main/java/com/jagrosh/discordipc/entities/ActivityType.java new file mode 100644 index 0000000..d8ed770 --- /dev/null +++ b/src/main/java/com/jagrosh/discordipc/entities/ActivityType.java @@ -0,0 +1,71 @@ +/* + * Copyright 2017 John Grosh (john.a.grosh@gmail.com). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jagrosh.discordipc.entities; + +/** + * Constants representing various Discord client activity types, + * such as Playing or Listening + */ +public enum ActivityType { + /** + * Constant for the "Playing" Discord RPC Activity type. + */ + Playing, + + /** + * Constant for the "Streaming" Discord RPC Activity type. + */ + Streaming, + + /** + * Constant for the "Listening" Discord RPC Activity type. + */ + Listening, + + /** + * Constant for the "Watching" Discord RPC Activity type. + */ + Watching, + + /** + * Constant for the "Custom" Discord RPC Activity type. + */ + Custom, + + /** + * Constant for the "Competing" Discord RPC Activity type. + */ + Competing; + + /** + * Gets a {@link ActivityType} matching the specified index. + *

+ * This is only internally implemented. + * + * @param index The index to get from. + * @return The {@link ActivityType} corresponding to the parameters, or + * {@link ActivityType#Playing} if none match. + */ + public static ActivityType from(int index) { + for (ActivityType value : values()) { + if (value.ordinal() == index) { + return value; + } + } + return Playing; + } +} diff --git a/src/main/java/com/jagrosh/discordipc/entities/Callback.java b/src/main/java/com/jagrosh/discordipc/entities/Callback.java index 128fcdf..2694299 100644 --- a/src/main/java/com/jagrosh/discordipc/entities/Callback.java +++ b/src/main/java/com/jagrosh/discordipc/entities/Callback.java @@ -13,29 +13,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package com.jagrosh.discordipc.entities; +import com.jagrosh.discordipc.IPCClient; + import java.util.function.Consumer; /** * A callback for asynchronous logic when dealing processes that - * would normally block the calling thread.

- * - * This is most visibly implemented in {@link com.jagrosh.discordipc.IPCClient IPCClient}. + * would normally block the calling thread. + *

+ * This is most visibly implemented in {@link IPCClient IPCClient}. * * @author John Grosh (john.a.grosh@gmail.com) */ -public class Callback -{ +public class Callback { private final Consumer success; private final Consumer failure; /** * Constructs an empty Callback. */ - public Callback() - { - this((Consumer) null, null); + public Callback() { + this(null, null); } /** @@ -45,8 +46,7 @@ public Callback() * * @param success The Consumer to launch after a successful process. */ - public Callback(Consumer success) - { + public Callback(Consumer success) { this(success, null); } @@ -58,52 +58,32 @@ public Callback(Consumer success) * @param success The Consumer to launch after a successful process. * @param failure The Consumer to launch if the process has an error. */ - public Callback(Consumer success, Consumer failure) - { + public Callback(Consumer success, Consumer failure) { this.success = success; this.failure = failure; } /** - * @param success The Runnable to launch after a successful process. - * @param failure The Consumer to launch if the process has an error. - */ - @Deprecated - public Callback(Runnable success, Consumer failure) - { - this(p -> success.run(), failure); - } - - /** - * @param success The Runnable to launch after a successful process. - */ - @Deprecated - public Callback(Runnable success) - { - this(p -> success.run(), null); - } - - /** - * Gets whether or not this Callback is "empty" which is more precisely + * Gets whether this Callback is "empty" which is more precisely * defined as not having a specified success {@link Consumer} and/or a * failure {@link Consumer}.
* This is only true if the Callback is constructed with the parameter-less * constructor ({@link #Callback()}) or another constructor that leaves * one or both parameters {@code null}. * - * @return {@code true} if and only if the + * @return {@code true} if and only if the Callback is "empty" */ - public boolean isEmpty() - { + public boolean isEmpty() { return success == null && failure == null; } /** * Launches the success {@link Consumer}. + * + * @param packet The packet to execute after success */ - public void succeed(Packet packet) - { - if(success != null) + public void succeed(Packet packet) { + if (success != null) success.accept(packet); } @@ -113,9 +93,8 @@ public void succeed(Packet packet) * * @param message The message to launch the failure consumer with. */ - public void fail(String message) - { - if(failure != null) + public void fail(String message) { + if (failure != null) failure.accept(message); } } diff --git a/src/main/java/com/jagrosh/discordipc/entities/DiscordBuild.java b/src/main/java/com/jagrosh/discordipc/entities/DiscordBuild.java index 2fa3967..af28908 100644 --- a/src/main/java/com/jagrosh/discordipc/entities/DiscordBuild.java +++ b/src/main/java/com/jagrosh/discordipc/entities/DiscordBuild.java @@ -1,11 +1,11 @@ /* - * Copyright 2017 Kaidan Gustave + * Copyright 2017 John Grosh (john.a.grosh@gmail.com). * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -13,66 +13,80 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package com.jagrosh.discordipc.entities; +import com.jagrosh.discordipc.IPCClient; + /** * Constants representing various Discord client builds, * such as Stable, Canary, Public Test Build (PTB) */ -public enum DiscordBuild -{ +public enum DiscordBuild { /** * Constant for the current Discord Canary release. */ - CANARY("//canary.discordapp.com/api"), + CANARY("//canary.discord.com/api"), /** * Constant for the current Discord Public Test Build or PTB release. */ - PTB("//ptb.discordapp.com/api"), + PTB("//ptb.discord.com/api"), /** * Constant for the current stable Discord release. */ - STABLE("//discordapp.com/api"), + STABLE("//discord.com/api"), /** - * 'Wildcard' build constant used in {@link com.jagrosh.discordipc.IPCClient#connect(DiscordBuild...) + * 'Wildcard' build constant used in {@link IPCClient#connect(DiscordBuild...) * IPCClient#connect(DiscordBuild...)} to signify that the build to target is not important, and - * that the first valid build will be used.

- * + * that the first valid build will be used. + *

* Other than this exact function, there is no use for this value. */ ANY; private final String endpoint; - DiscordBuild(String endpoint) - { + DiscordBuild(String endpoint) { this.endpoint = endpoint; } - DiscordBuild() - { + DiscordBuild() { this(null); } /** - * Gets a {@link DiscordBuild} matching the specified endpoint.

+ * Gets a {@link DiscordBuild} matching the specified index. + *

+ * This is only internally implemented. * + * @param index The index to get from. + * @return The {@link DiscordBuild} corresponding to the parameters, or + * {@link DiscordBuild#ANY} if none match. + */ + public static DiscordBuild from(int index) { + for (DiscordBuild value : values()) { + if (value.ordinal() == index) { + return value; + } + } + return ANY; + } + + /** + * Gets a {@link DiscordBuild} matching the specified endpoint. + *

* This is only internally implemented. * * @param endpoint The endpoint to get from. - * * @return The DiscordBuild corresponding to the endpoint, or - * {@link DiscordBuild#ANY} if none match. + * {@link DiscordBuild#ANY} if none match. */ - public static DiscordBuild from(String endpoint) - { - for(DiscordBuild value : values()) - { - if(value.endpoint != null && value.endpoint.equals(endpoint)) - { + public static DiscordBuild from(String endpoint) { + for (DiscordBuild value : values()) { + if (value.endpoint != null && value.endpoint.equals(endpoint)) { return value; } } diff --git a/src/main/java/com/jagrosh/discordipc/entities/Packet.java b/src/main/java/com/jagrosh/discordipc/entities/Packet.java index 26d218d..36f789c 100644 --- a/src/main/java/com/jagrosh/discordipc/entities/Packet.java +++ b/src/main/java/com/jagrosh/discordipc/entities/Packet.java @@ -13,33 +13,49 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package com.jagrosh.discordipc.entities; +import com.google.gson.JsonObject; +import com.jagrosh.discordipc.IPCClient; +import com.jagrosh.discordipc.IPCListener; + +import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import org.json.JSONObject; /** - * A data-packet received from Discord via an {@link com.jagrosh.discordipc.IPCClient IPCClient}.
- * These can be handled via an implementation of {@link com.jagrosh.discordipc.IPCListener IPCListener}. + * A data-packet received from Discord via an {@link IPCClient IPCClient}.
+ * These can be handled via an implementation of {@link IPCListener IPCListener}. * * @author John Grosh (john.a.grosh@gmail.com) */ -public class Packet -{ +public class Packet { private final OpCode op; - private final JSONObject data; + private final JsonObject data; + private final String encoding; /** - * Constructs a new Packet using an {@link OpCode} and {@link JSONObject}. + * Constructs a new Packet using an {@link OpCode} and {@link JsonObject}. * - * @param op The OpCode value of this new Packet. - * @param data The JSONObject payload of this new Packet. + * @param op The OpCode value of this new Packet. + * @param data The JSONObject payload of this new Packet. + * @param encoding encoding to send packets as */ - public Packet(OpCode op, JSONObject data) - { + public Packet(OpCode op, JsonObject data, String encoding) { this.op = op; this.data = data; + this.encoding = encoding; + } + + /** + * Constructs a new Packet using an {@link OpCode} and {@link JsonObject}. + * + * @param op The OpCode value of this new Packet. + * @param data The JSONObject payload of this new Packet. + */ + @Deprecated + public Packet(OpCode op, JsonObject data) { + this(op, data, "UTF-8"); } /** @@ -47,10 +63,17 @@ public Packet(OpCode op, JSONObject data) * * @return This Packet as a {@code byte} array. */ - public byte[] toBytes() - { - byte[] d = data.toString().getBytes(StandardCharsets.UTF_8); - ByteBuffer packet = ByteBuffer.allocate(d.length + 2*Integer.BYTES); + public byte[] toBytes() { + String s = data.toString(); + + byte[] d; + try { + d = s.getBytes(encoding); + } catch (UnsupportedEncodingException e) { + d = s.getBytes(); + } + + ByteBuffer packet = ByteBuffer.allocate(d.length + 2 * (Integer.SIZE / Byte.SIZE)); packet.putInt(Integer.reverseBytes(op.ordinal())); packet.putInt(Integer.reverseBytes(d.length)); packet.put(d); @@ -62,35 +85,39 @@ public byte[] toBytes() * * @return This Packet's OpCode. */ - public OpCode getOp() - { + public OpCode getOp() { return op; } /** - * Gets the {@link JSONObject} value as a part of this {@link Packet}. + * Gets the Raw {@link JsonObject} value as a part of this {@link Packet}. * * @return The JSONObject value of this Packet. */ - public JSONObject getJson() - { + public JsonObject getJson() { return data; } - + @Override - public String toString() - { - return "Pkt:"+getOp()+getJson().toString(); + public String toString() { + return "Pkt:" + getOp() + getJson().toString(); + } + + public String toDecodedString() { + try { + return "Pkt:" + getOp() + new String(getJson().toString().getBytes(encoding)); + } catch (UnsupportedEncodingException e) { + return "Pkt:" + getOp() + getJson().toString(); + } } /** * Discord response OpCode values that are * sent with response data to and from Discord - * and the {@link com.jagrosh.discordipc.IPCClient IPCClient} + * and the {@link IPCClient IPCClient} * connected. */ - public enum OpCode - { + public enum OpCode { HANDSHAKE, FRAME, CLOSE, PING, PONG } } diff --git a/src/main/java/com/jagrosh/discordipc/entities/PartyPrivacy.java b/src/main/java/com/jagrosh/discordipc/entities/PartyPrivacy.java new file mode 100644 index 0000000..8b7d3b0 --- /dev/null +++ b/src/main/java/com/jagrosh/discordipc/entities/PartyPrivacy.java @@ -0,0 +1,51 @@ +/* + * Copyright 2017 John Grosh (john.a.grosh@gmail.com). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jagrosh.discordipc.entities; + +/** + * Constants representing various Discord client party privacy levels, + * such as Public or Private + */ +public enum PartyPrivacy { + /** + * Constant for the "Private" Discord RPC Party privacy level. + */ + Private, + + /** + * Constant for the "Public" Discord RPC Party privacy level. + */ + Public; + + /** + * Gets a {@link PartyPrivacy} matching the specified index. + *

+ * This is only internally implemented. + * + * @param index The index to get from. + * @return The {@link PartyPrivacy} corresponding to the parameters, or + * {@link PartyPrivacy#Public} if none match. + */ + public static PartyPrivacy from(int index) { + for (PartyPrivacy value : values()) { + if (value.ordinal() == index) { + return value; + } + } + return Public; + } +} diff --git a/src/main/java/com/jagrosh/discordipc/entities/RichPresence.java b/src/main/java/com/jagrosh/discordipc/entities/RichPresence.java index 78774c5..77f4ecf 100644 --- a/src/main/java/com/jagrosh/discordipc/entities/RichPresence.java +++ b/src/main/java/com/jagrosh/discordipc/entities/RichPresence.java @@ -13,11 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package com.jagrosh.discordipc.entities; -import java.time.OffsetDateTime; -import org.json.JSONArray; -import org.json.JSONObject; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.util.Objects; /** * An encapsulation of all data needed to properly construct a JSON RichPresence payload. @@ -26,100 +29,272 @@ * * @author John Grosh (john.a.grosh@gmail.com) */ -public class RichPresence -{ +public class RichPresence { + private static final int MIN_ALLOWED_BUTTONS = 1; + private static final int MAX_ALLOWED_BUTTONS = 2; + private final ActivityType activityType; + private final StatusDisplayType statusDisplayType; private final String state; + private final String stateUrl; private final String details; - private final OffsetDateTime startTimestamp; - private final OffsetDateTime endTimestamp; + private final String detailsUrl; + private final String name; + private final long startTimestamp; + private final long endTimestamp; private final String largeImageKey; private final String largeImageText; + private final String largeImageUrl; private final String smallImageKey; private final String smallImageText; + private final String smallImageUrl; private final String partyId; private final int partySize; private final int partyMax; + private final PartyPrivacy partyPrivacy; private final String matchSecret; private final String joinSecret; private final String spectateSecret; + private final JsonArray buttons; private final boolean instance; - - public RichPresence(String state, String details, OffsetDateTime startTimestamp, OffsetDateTime endTimestamp, - String largeImageKey, String largeImageText, String smallImageKey, String smallImageText, - String partyId, int partySize, int partyMax, String matchSecret, String joinSecret, - String spectateSecret, boolean instance) - { + + public RichPresence(ActivityType activityType, StatusDisplayType statusDisplayType, + String state, String stateUrl, + String details, String detailsUrl, + String name, long startTimestamp, long endTimestamp, + String largeImageKey, String largeImageText, String largeImageUrl, + String smallImageKey, String smallImageText, String smallImageUrl, + String partyId, int partySize, int partyMax, PartyPrivacy partyPrivacy, + String matchSecret, String joinSecret, String spectateSecret, + JsonArray buttons, boolean instance) { + this.activityType = activityType; + this.statusDisplayType = statusDisplayType; this.state = state; + this.stateUrl = stateUrl; this.details = details; + this.detailsUrl = detailsUrl; + this.name = name; this.startTimestamp = startTimestamp; this.endTimestamp = endTimestamp; this.largeImageKey = largeImageKey; this.largeImageText = largeImageText; + this.largeImageUrl = largeImageUrl; this.smallImageKey = smallImageKey; this.smallImageText = smallImageText; + this.smallImageUrl = smallImageUrl; this.partyId = partyId; this.partySize = partySize; this.partyMax = partyMax; + this.partyPrivacy = partyPrivacy; this.matchSecret = matchSecret; this.joinSecret = joinSecret; this.spectateSecret = spectateSecret; + this.buttons = buttons; this.instance = instance; } /** - * Constructs a {@link JSONObject} representing a payload to send to discord + * Constructs a {@link JsonObject} representing a payload to send to discord * to update a user's Rich Presence. * - *

This is purely internal, and should not ever need to be called outside of + *

This is purely internal, and should not ever need to be called outside * the library. * * @return A JSONObject payload for updating a user's Rich Presence. */ - public JSONObject toJson() - { - return new JSONObject() - .put("state", state) - .put("details", details) - .put("timestamps", new JSONObject() - .put("start", startTimestamp==null ? null : startTimestamp.toEpochSecond()) - .put("end", endTimestamp==null ? null : endTimestamp.toEpochSecond())) - .put("assets", new JSONObject() - .put("large_image", largeImageKey) - .put("large_text", largeImageText) - .put("small_image", smallImageKey) - .put("small_text", smallImageText)) - .put("party", partyId==null ? null : new JSONObject() - .put("id", partyId) - .put("size", new JSONArray().put(partySize).put(partyMax))) - .put("secrets", new JSONObject() - .put("join", joinSecret) - .put("spectate", spectateSecret) - .put("match", matchSecret)) - .put("instance", instance); + public JsonObject toJson() { + JsonObject timestamps = new JsonObject(), + assets = new JsonObject(), + party = new JsonObject(), + secrets = new JsonObject(), + finalObject = new JsonObject(); + + if (startTimestamp > 0) { + timestamps.addProperty("start", startTimestamp); + + if (endTimestamp > startTimestamp) { + timestamps.addProperty("end", endTimestamp); + } + } + + if (largeImageKey != null && !largeImageKey.isEmpty()) { + assets.addProperty("large_image", largeImageKey); + + if (largeImageText != null && !largeImageText.isEmpty()) { + assets.addProperty("large_text", largeImageText); + } + if (largeImageUrl != null && !largeImageUrl.isEmpty()) { + assets.addProperty("large_url", largeImageUrl); + } + } + + if (smallImageKey != null && !smallImageKey.isEmpty()) { + assets.addProperty("small_image", smallImageKey); + + if (smallImageText != null && !smallImageText.isEmpty()) { + assets.addProperty("small_text", smallImageText); + } + if (smallImageUrl != null && !smallImageUrl.isEmpty()) { + assets.addProperty("small_url", smallImageUrl); + } + } + + if ((partyId != null && !partyId.isEmpty()) || + (partySize > 0 && partyMax > 0)) { + if (partyId != null && !partyId.isEmpty()) { + party.addProperty("id", partyId); + } + + JsonArray partyData = new JsonArray(); + + if (partySize > 0) { + partyData.add(new JsonPrimitive(partySize)); + + if (partyMax >= partySize) { + partyData.add(new JsonPrimitive(partyMax)); + } + } + party.add("size", partyData); + party.add("privacy", new JsonPrimitive(partyPrivacy.ordinal())); + } + + if (joinSecret != null && !joinSecret.isEmpty()) { + secrets.addProperty("join", joinSecret); + } + if (spectateSecret != null && !spectateSecret.isEmpty()) { + secrets.addProperty("spectate", spectateSecret); + } + if (matchSecret != null && !matchSecret.isEmpty()) { + secrets.addProperty("match", matchSecret); + } + + finalObject.addProperty("type", activityType.ordinal()); + finalObject.addProperty("status_display_type", statusDisplayType.ordinal()); + + if (state != null && !state.isEmpty()) { + finalObject.addProperty("state", state); + + if (stateUrl != null && !stateUrl.isEmpty()) { + finalObject.addProperty("state_url", stateUrl); + } + } + if (details != null && !details.isEmpty()) { + finalObject.addProperty("details", details); + + if (detailsUrl != null && !detailsUrl.isEmpty()) { + finalObject.addProperty("details_url", detailsUrl); + } + } + + if (name != null && !name.isEmpty()) { + finalObject.addProperty("name", name); + } + + if (timestamps.has("start")) { + finalObject.add("timestamps", timestamps); + } + if (assets.has("large_image")) { + finalObject.add("assets", assets); + } + if (party.has("id")) { + finalObject.add("party", party); + } + if (secrets.has("join") || secrets.has("spectate") || secrets.has("match")) { + finalObject.add("secrets", secrets); + } + if (buttons != null && !buttons.isJsonNull() && buttons.size() >= MIN_ALLOWED_BUTTONS && buttons.size() <= MAX_ALLOWED_BUTTONS) { + finalObject.add("buttons", buttons); + } + finalObject.addProperty("instance", instance); + + return finalObject; + } + + public String toDecodedJson(String encoding) { + try { + return new String(toJson().toString().getBytes(encoding)); + } catch (Exception ex) { + return toJson().toString(); + } + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof RichPresence)) + return false; + RichPresence oPresence = (RichPresence) o; + return this == oPresence || ( + Objects.equals(activityType, oPresence.activityType) && + Objects.equals(statusDisplayType, oPresence.statusDisplayType) && + Objects.equals(state, oPresence.state) && + Objects.equals(stateUrl, oPresence.stateUrl) && + Objects.equals(details, oPresence.details) && + Objects.equals(detailsUrl, oPresence.detailsUrl) && + Objects.equals(name, oPresence.name) && + Objects.equals(startTimestamp, oPresence.startTimestamp) && + Objects.equals(endTimestamp, oPresence.endTimestamp) && + Objects.equals(largeImageKey, oPresence.largeImageKey) && + Objects.equals(largeImageText, oPresence.largeImageText) && + Objects.equals(largeImageUrl, oPresence.largeImageUrl) && + Objects.equals(smallImageKey, oPresence.smallImageKey) && + Objects.equals(smallImageText, oPresence.smallImageText) && + Objects.equals(smallImageUrl, oPresence.smallImageUrl) && + Objects.equals(partyId, oPresence.partyId) && + Objects.equals(partySize, oPresence.partySize) && + Objects.equals(partyMax, oPresence.partyMax) && + Objects.equals(partyPrivacy, oPresence.partyPrivacy) && + Objects.equals(matchSecret, oPresence.matchSecret) && + Objects.equals(joinSecret, oPresence.joinSecret) && + Objects.equals(spectateSecret, oPresence.spectateSecret) && + Objects.equals(buttons, oPresence.buttons) && + Objects.equals(instance, oPresence.instance) + ); + } + + @Override + public int hashCode() { + return Objects.hash( + activityType, statusDisplayType, + state, stateUrl, + details, detailsUrl, + name, startTimestamp, endTimestamp, + largeImageKey, largeImageText, largeImageUrl, + smallImageKey, smallImageText, smallImageUrl, + partyId, partySize, partyMax, partyPrivacy, + matchSecret, joinSecret, spectateSecret, + buttons, instance + ); } /** * A chain builder for a {@link RichPresence} object. * *

An accurate description of each field and it's functions can be found - * here + * here */ - public static class Builder - { + public static class Builder { + private ActivityType activityType; + private StatusDisplayType statusDisplayType; private String state; + private String stateUrl; private String details; - private OffsetDateTime startTimestamp; - private OffsetDateTime endTimestamp; + private String detailsUrl; + private String name; + private long startTimestamp; + private long endTimestamp; private String largeImageKey; private String largeImageText; + private String largeImageUrl; private String smallImageKey; private String smallImageText; + private String smallImageUrl; private String partyId; private int partySize; private int partyMax; + private PartyPrivacy partyPrivacy; private String matchSecret; private String joinSecret; private String spectateSecret; + private JsonArray buttons; private boolean instance; /** @@ -127,49 +302,102 @@ public static class Builder * * @return The RichPresence built. */ - public RichPresence build() - { - return new RichPresence(state, details, startTimestamp, endTimestamp, - largeImageKey, largeImageText, smallImageKey, smallImageText, - partyId, partySize, partyMax, matchSecret, joinSecret, - spectateSecret, instance); + public RichPresence build() { + return new RichPresence(activityType, statusDisplayType, + state, stateUrl, + details, detailsUrl, + name, startTimestamp, endTimestamp, + largeImageKey, largeImageText, largeImageUrl, + smallImageKey, smallImageText, smallImageUrl, + partyId, partySize, partyMax, partyPrivacy, + matchSecret, joinSecret, spectateSecret, + buttons, instance); + } + + /** + * Sets the activity type for the player's current activity + * + * @param activityType The new activity type + * @return This Builder. + */ + public Builder setActivityType(ActivityType activityType) { + this.activityType = activityType; + return this; + } + + /** + * Sets the status display type for the player's current activity + * + * @param statusDisplayType The new status display type + * @return This Builder. + */ + public Builder setStatusDisplayType(StatusDisplayType statusDisplayType) { + this.statusDisplayType = statusDisplayType; + return this; } /** * Sets the state of the user's current party. * * @param state The state of the user's current party. - * * @return This Builder. */ - public Builder setState(String state) - { + public Builder setState(String state) { this.state = state; return this; } + /** + * Sets the state url of the user's current party + * + * @param stateUrl The state url of the user's current party. + * @return This Builder. + */ + public Builder setStateUrl(String stateUrl) { + this.stateUrl = stateUrl; + return this; + } + /** * Sets details of what the player is currently doing. * * @param details The details of what the player is currently doing. - * * @return This Builder. */ - public Builder setDetails(String details) - { + public Builder setDetails(String details) { this.details = details; return this; } + /** + * Sets the details url of what the player is currently doing. + * + * @param detailsUrl The details url of what the player is currently doing + * @return This Builder. + */ + public Builder setDetailsUrl(String detailsUrl) { + this.detailsUrl = detailsUrl; + return this; + } + + /** + * Sets the player activity name. + * + * @param name The player activity name. + * @return This Builder. + */ + public Builder setName(String name) { + this.name = name; + return this; + } + /** * Sets the time that the player started a match or activity. * * @param startTimestamp The time the player started a match or activity. - * * @return This Builder. */ - public Builder setStartTimestamp(OffsetDateTime startTimestamp) - { + public Builder setStartTimestamp(long startTimestamp) { this.startTimestamp = startTimestamp; return this; } @@ -178,81 +406,135 @@ public Builder setStartTimestamp(OffsetDateTime startTimestamp) * Sets the time that the player's current activity will end. * * @param endTimestamp The time the player's activity will end. - * * @return This Builder. */ - public Builder setEndTimestamp(OffsetDateTime endTimestamp) - { + public Builder setEndTimestamp(long endTimestamp) { this.endTimestamp = endTimestamp; return this; } /** * Sets the key of the uploaded image for the large profile artwork, as well as - * the text tooltip shown when a cursor hovers over it. + * the text tooltip shown when a cursor hovers over it, and the url when clicked. * - *

These can be configured in the applications + *

These can be configured in the applications * page on the discord website. * - * @param largeImageKey A key to an image to display. + * @param largeImageKey A key to an image to display. * @param largeImageText Text displayed when a cursor hovers over the large image. - * + * @param largeImageUrl The Url to navigate to when clicking the large image. * @return This Builder. */ - public Builder setLargeImage(String largeImageKey, String largeImageText) - { + public Builder setLargeImage(String largeImageKey, String largeImageText, String largeImageUrl) { this.largeImageKey = largeImageKey; this.largeImageText = largeImageText; + this.largeImageUrl = largeImageUrl; return this; } + /** + * Sets the key of the uploaded image for the large profile artwork, as well as + * the text tooltip shown when a cursor hovers over it. + * + *

These can be configured in the applications + * page on the discord website. + * + * @param largeImageKey A key to an image to display. + * @param largeImageText Text displayed when a cursor hovers over the large image. + * @return This Builder. + */ + public Builder setLargeImageWithTooltip(String largeImageKey, String largeImageText) { + return setLargeImage(largeImageKey, largeImageText, null); + } + + /** + * Sets the key of the uploaded image for the large profile artwork, as well as + * the url to navigate to when clicked. + * + *

These can be configured in the applications + * page on the discord website. + * + * @param largeImageKey A key to an image to display. + * @param largeImageUrl The Url to navigate to when clicking the large image. + * @return This Builder. + */ + public Builder setLargeImageWithUrl(String largeImageKey, String largeImageUrl) { + return setLargeImage(largeImageKey, null, largeImageUrl); + } + /** * Sets the key of the uploaded image for the large profile artwork. * - *

These can be configured in the applications + *

These can be configured in the applications * page on the discord website. * * @param largeImageKey A key to an image to display. - * * @return This Builder. */ - public Builder setLargeImage(String largeImageKey) - { - return setLargeImage(largeImageKey, null); + public Builder setLargeImage(String largeImageKey) { + return setLargeImage(largeImageKey, null, null); } /** * Sets the key of the uploaded image for the small profile artwork, as well as - * the text tooltip shown when a cursor hovers over it. + * the text tooltip shown when a cursor hovers over it, and the url when clicked. * - *

These can be configured in the applications + *

These can be configured in the applications * page on the discord website. * - * @param smallImageKey A key to an image to display. + * @param smallImageKey A key to an image to display. * @param smallImageText Text displayed when a cursor hovers over the small image. - * + * @param smallImageUrl The Url to navigate to when clicking the small image. * @return This Builder. */ - public Builder setSmallImage(String smallImageKey, String smallImageText) - { + public Builder setSmallImage(String smallImageKey, String smallImageText, String smallImageUrl) { this.smallImageKey = smallImageKey; this.smallImageText = smallImageText; + this.smallImageUrl = smallImageUrl; return this; } + /** + * Sets the key of the uploaded image for the small profile artwork, as well as + * the text tooltip shown when a cursor hovers over it. + * + *

These can be configured in the applications + * page on the discord website. + * + * @param smallImageKey A key to an image to display. + * @param smallImageText Text displayed when a cursor hovers over the small image. + * @return This Builder. + */ + public Builder setSmallImageWithTooltip(String smallImageKey, String smallImageText) { + return setSmallImage(smallImageKey, smallImageText, null); + } + + /** + * Sets the key of the uploaded image for the small profile artwork, as well as + * the url to navigate to when clicked. + * + *

These can be configured in the applications + * page on the discord website. + * + * @param smallImageKey A key to an image to display. + * @param smallImageUrl The Url to navigate to when clicking the small image. + * @return This Builder. + */ + public Builder setSmallImageWithUrl(String smallImageKey, String smallImageUrl) { + return setSmallImage(smallImageKey, null, smallImageUrl); + } + /** * Sets the key of the uploaded image for the small profile artwork. * - *

These can be configured in the applications + *

These can be configured in the applications * page on the discord website. * * @param smallImageKey A key to an image to display. - * * @return This Builder. */ - public Builder setSmallImage(String smallImageKey) - { - return setSmallImage(smallImageKey, null); + public Builder setSmallImage(String smallImageKey) { + return setSmallImage(smallImageKey, null, null); } /** @@ -262,17 +544,17 @@ public Builder setSmallImage(String smallImageKey) *
The {@code partySize} is the current size of the player's party. *
The {@code partyMax} is the maximum number of player's allowed in the party. * - * @param partyId The ID of the player's party. - * @param partySize The current size of the player's party. - * @param partyMax The maximum number of player's allowed in the party. - * + * @param partyId The ID of the player's party. + * @param partySize The current size of the player's party. + * @param partyMax The maximum number of player's allowed in the party. + * @param partyPrivacy The privacy level for the player's party. * @return This Builder. */ - public Builder setParty(String partyId, int partySize, int partyMax) - { + public Builder setParty(String partyId, int partySize, int partyMax, PartyPrivacy partyPrivacy) { this.partyId = partyId; this.partySize = partySize; this.partyMax = partyMax; + this.partyPrivacy = partyPrivacy; return this; } @@ -280,11 +562,9 @@ public Builder setParty(String partyId, int partySize, int partyMax) * Sets the unique hashed string for Spectate and Join. * * @param matchSecret The unique hashed string for Spectate and Join. - * * @return This Builder. */ - public Builder setMatchSecret(String matchSecret) - { + public Builder setMatchSecret(String matchSecret) { this.matchSecret = matchSecret; return this; } @@ -293,11 +573,9 @@ public Builder setMatchSecret(String matchSecret) * Sets the unique hashed string for chat invitations and Ask to Join. * * @param joinSecret The unique hashed string for chat invitations and Ask to Join. - * * @return This Builder. */ - public Builder setJoinSecret(String joinSecret) - { + public Builder setJoinSecret(String joinSecret) { this.joinSecret = joinSecret; return this; } @@ -306,26 +584,34 @@ public Builder setJoinSecret(String joinSecret) * Sets the unique hashed string for Spectate button. * * @param spectateSecret The unique hashed string for Spectate button. - * * @return This Builder. */ - public Builder setSpectateSecret(String spectateSecret) - { + public Builder setSpectateSecret(String spectateSecret) { this.spectateSecret = spectateSecret; return this; } + /** + * Sets the button array to be used within the RichPresence + *

Must be a format of {'label': "...", 'url': "..."} with a max length of 2

+ * + * @param buttons The new array of button objects to use + * @return This Builder. + */ + public Builder setButtons(JsonArray buttons) { + this.buttons = buttons; + return this; + } + /** * Marks the {@link #setMatchSecret(String) matchSecret} as a game * session with a specific beginning and end. * - * @param instance Whether or not the {@code matchSecret} is a game + * @param instance Whether the {@code matchSecret} is a game * with a specific beginning and end. - * * @return This Builder. */ - public Builder setInstance(boolean instance) - { + public Builder setInstance(boolean instance) { this.instance = instance; return this; } diff --git a/src/main/java/com/jagrosh/discordipc/entities/StatusDisplayType.java b/src/main/java/com/jagrosh/discordipc/entities/StatusDisplayType.java new file mode 100644 index 0000000..d751fef --- /dev/null +++ b/src/main/java/com/jagrosh/discordipc/entities/StatusDisplayType.java @@ -0,0 +1,38 @@ +package com.jagrosh.discordipc.entities; + +/** + * Constants representing various Discord client status display types, + * such as Name, State, or Details + */ +public enum StatusDisplayType { + /** + * Constant for the "Name" Discord RPC Status type. + */ + Name, + /** + * Constant for the "State" Discord RPC Status type. + */ + State, + /** + * Constant for the "Details" Discord RPC Status type. + */ + Details; + + /** + * Gets a {@link StatusDisplayType} matching the specified index. + *

+ * This is only internally implemented. + * + * @param index The index to get from. + * @return The {@link StatusDisplayType} corresponding to the parameters, or + * {@link StatusDisplayType#Name} if none match. + */ + public static StatusDisplayType from(int index) { + for (StatusDisplayType value : values()) { + if (value.ordinal() == index) { + return value; + } + } + return Name; + } +} diff --git a/src/main/java/com/jagrosh/discordipc/entities/User.java b/src/main/java/com/jagrosh/discordipc/entities/User.java index 80ea17d..e6985d8 100644 --- a/src/main/java/com/jagrosh/discordipc/entities/User.java +++ b/src/main/java/com/jagrosh/discordipc/entities/User.java @@ -13,19 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package com.jagrosh.discordipc.entities; +import com.jagrosh.discordipc.IPCClient; +import com.jagrosh.discordipc.IPCListener; + /** - * A encapsulation of a Discord User's data provided when a - * {@link com.jagrosh.discordipc.IPCListener IPCListener} fires - * {@link com.jagrosh.discordipc.IPCListener#onActivityJoinRequest(com.jagrosh.discordipc.IPCClient, String, User) + * An encapsulation of a Discord User's data provided when a + * {@link IPCListener IPCListener} fires + * {@link IPCListener#onActivityJoinRequest(IPCClient, String, User) * onActivityJoinRequest}. * * @author John Grosh (john.a.grosh@gmail.com) */ -public class User -{ - private final String name; +public class User { + private final String username; + private final String nickname; private final String discriminator; private final long id; private final String avatar; @@ -33,14 +37,16 @@ public class User /** * Constructs a new {@link User}.
* Only implemented internally. - * @param name user's name - * @param discriminator user's discrim - * @param id user's id - * @param avatar user's avatar hash, or {@code null} if they have no avatar + * + * @param username user's name + * @param nickname user's nickname + * @param discriminator user's discriminator + * @param id user's id + * @param avatar user's avatar hash, or {@code null} if they have no avatar */ - public User(String name, String discriminator, long id, String avatar) - { - this.name = name; + public User(String username, String nickname, String discriminator, long id, String avatar) { + this.username = username; + this.nickname = nickname; this.discriminator = discriminator; this.id = id; this.avatar = avatar; @@ -51,9 +57,27 @@ public User(String name, String discriminator, long id, String avatar) * * @return The Users account name. */ - public String getName() - { - return name; + public String getName() { + return username; + } + + /** + * Gets the Users nickname, if any. + * + * @return The Users nickname. + */ + public String getNickname() { + return nickname; + } + + /** + * Gets the Users nickname, or their account name if they + * do not have a custom nickname set on their account. + * + * @return The Users effective name. + */ + public String getEffectiveName() { + return nickname == null ? username : nickname; } /** @@ -61,8 +85,7 @@ public String getName() * * @return The Users discriminator. */ - public String getDiscriminator() - { + public String getDiscriminator() { return discriminator; } @@ -71,8 +94,7 @@ public String getDiscriminator() * * @return The Users Snowflake ID as a {@code long}. */ - public long getIdLong() - { + public long getIdLong() { return id; } @@ -81,8 +103,7 @@ public long getIdLong() * * @return The Users Snowflake ID as a {@code String}. */ - public String getId() - { + public String getId() { return Long.toString(id); } @@ -91,8 +112,7 @@ public String getId() * * @return The Users avatar ID. */ - public String getAvatarId() - { + public String getAvatarId() { return avatar; } @@ -101,10 +121,9 @@ public String getAvatarId() * * @return The Users avatar URL. */ - public String getAvatarUrl() - { + public String getAvatarUrl() { return getAvatarId() == null ? null : "https://cdn.discordapp.com/avatars/" + getId() + "/" + getAvatarId() - + (getAvatarId().startsWith("a_") ? ".gif" : ".png"); + + (getAvatarId().startsWith("a_") ? ".gif" : ".png"); } /** @@ -112,9 +131,14 @@ public String getAvatarUrl() * * @return The Users {@link DefaultAvatar} avatar ID. */ - public String getDefaultAvatarId() - { - return DefaultAvatar.values()[Integer.parseInt(getDiscriminator()) % DefaultAvatar.values().length].toString(); + public String getDefaultAvatarId() { + int index; + if (getDiscriminator().equals("0")) { + index = ((int) getIdLong() >> 22) % 6; + } else { + index = Integer.parseInt(getDiscriminator()) % 5; + } + return DefaultAvatar.values()[index].toString(); } /** @@ -122,9 +146,8 @@ public String getDefaultAvatarId() * * @return The Users {@link DefaultAvatar} avatar URL. */ - public String getDefaultAvatarUrl() - { - return "https://discordapp.com/assets/" + getDefaultAvatarId() + ".png"; + public String getDefaultAvatarUrl() { + return "https://discord.com/assets/" + getDefaultAvatarId() + ".png"; } /** @@ -133,55 +156,49 @@ public String getDefaultAvatarUrl() * * @return The Users effective avatar URL. */ - public String getEffectiveAvatarUrl() - { + public String getEffectiveAvatarUrl() { return getAvatarUrl() == null ? getDefaultAvatarUrl() : getAvatarUrl(); } /** - * Gets whether or not this User is a bot.

- * + * Gets whether this User is a bot. + *

* While, at the time of writing this documentation, bots cannot * use Rich Presence features, there may be a time in the future * where they have such an ability. * * @return False */ - public boolean isBot() - { + public boolean isBot() { return false; //bots cannot use RPC } /** - * Gets the User as a discord formatted mention.

- * + * Gets the User as a discord formatted mention. + *

* {@code <@SNOWFLAKE_ID> } * * @return A discord formatted mention of this User. */ - public String getAsMention() - { + public String getAsMention() { return "<@" + id + '>'; } - + @Override - public boolean equals(Object o) - { + public boolean equals(Object o) { if (!(o instanceof User)) return false; User oUser = (User) o; return this == oUser || this.id == oUser.id; } - + @Override - public int hashCode() - { + public int hashCode() { return Long.hashCode(id); } @Override - public String toString() - { + public String toString() { return "U:" + getName() + '(' + id + ')'; } @@ -189,24 +206,22 @@ public String toString() * Constants representing one of five different * default avatars a {@link User} can have. */ - public enum DefaultAvatar - { + public enum DefaultAvatar { BLURPLE("6debd47ed13483642cf09e832ed0bc1b"), GREY("322c936a8c8be1b803cd94861bdfa868"), GREEN("dd4dbc0016779df1378e7812eabaa04d"), ORANGE("0e291f67c9274a1abdddeb3fd919cbaa"), - RED("1cbd08c76f8af6dddce02c5138971129"); + RED("1cbd08c76f8af6dddce02c5138971129"), + PINK("1b3106e166c99cc64682"); private final String text; - DefaultAvatar(String text) - { + DefaultAvatar(String text) { this.text = text; } @Override - public String toString() - { + public String toString() { return text; } } diff --git a/src/main/java/com/jagrosh/discordipc/entities/pipe/MacPipe.java b/src/main/java/com/jagrosh/discordipc/entities/pipe/MacPipe.java new file mode 100644 index 0000000..91cf346 --- /dev/null +++ b/src/main/java/com/jagrosh/discordipc/entities/pipe/MacPipe.java @@ -0,0 +1,77 @@ +/* + * Copyright 2017 John Grosh (john.a.grosh@gmail.com). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jagrosh.discordipc.entities.pipe; + +import com.jagrosh.discordipc.IPCClient; +import com.jagrosh.discordipc.entities.Callback; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.HashMap; + +public class MacPipe extends UnixPipe { + + MacPipe(IPCClient ipcClient, HashMap callbacks, File location) throws IOException { + super(ipcClient, callbacks, location); + } + + private void registerCommand(String applicationId, String command) { + String home = System.getenv("HOME"); + if (home == null) + throw new RuntimeException("Unable to find user HOME directory"); + + String path = home + "/Library/Application Support/discord"; + + if (!this.mkdir(path)) + throw new RuntimeException("Failed to create directory '" + path + "'"); + + path += "/games"; + + if (!this.mkdir(path)) + throw new RuntimeException("Failed to create directory '" + path + "'"); + + path += "/" + applicationId + ".json"; + + try (FileWriter fileWriter = new FileWriter(path)) { + fileWriter.write("{\"command\": \"" + command + "\"}"); + } catch (Exception ex) { + throw new RuntimeException("Failed to write fame info into '" + path + "'"); + } + } + + private void registerUrl(String applicationId) { + throw new UnsupportedOperationException("MacOS URL registration is not supported at this time."); + } + + @Override + public void registerApp(String applicationId, String command) { + try { + if (command != null) + this.registerCommand(applicationId, command); + else + this.registerUrl(applicationId); + } catch (Exception ex) { + throw new RuntimeException("Failed to register " + (command == null ? "url" : "command"), ex); + } + } + + @Override + public void registerSteamGame(String applicationId, String steamId) { + this.registerApp(applicationId, "steam://rungameid/" + steamId); + } +} diff --git a/src/main/java/com/jagrosh/discordipc/entities/pipe/Pipe.java b/src/main/java/com/jagrosh/discordipc/entities/pipe/Pipe.java index 8ab8460..3f68c38 100644 --- a/src/main/java/com/jagrosh/discordipc/entities/pipe/Pipe.java +++ b/src/main/java/com/jagrosh/discordipc/entities/pipe/Pipe.java @@ -16,131 +16,164 @@ package com.jagrosh.discordipc.entities.pipe; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; import com.jagrosh.discordipc.IPCClient; import com.jagrosh.discordipc.IPCListener; import com.jagrosh.discordipc.entities.Callback; import com.jagrosh.discordipc.entities.DiscordBuild; import com.jagrosh.discordipc.entities.Packet; +import com.jagrosh.discordipc.entities.User; import com.jagrosh.discordipc.exceptions.NoDiscordClientException; -import org.json.JSONException; -import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; import java.io.IOException; import java.util.HashMap; import java.util.UUID; public abstract class Pipe { - private static final Logger LOGGER = LoggerFactory.getLogger(Pipe.class); private static final int VERSION = 1; + // a list of system property keys to get IPC file from different unix systems. + private final static String[] unixPaths = {"XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"}; + private final static String[] unixFolderPaths = {"/snap.discord", "/app/com.discordapp.Discord"}; + final IPCClient ipcClient; + private final HashMap callbacks; PipeStatus status = PipeStatus.CONNECTING; IPCListener listener; private DiscordBuild build; - final IPCClient ipcClient; - private final HashMap callbacks; + private User currentUser; - Pipe(IPCClient ipcClient, HashMap callbacks) - { + Pipe(IPCClient ipcClient, HashMap callbacks) { this.ipcClient = ipcClient; this.callbacks = callbacks; } - public static Pipe openPipe(IPCClient ipcClient, long clientId, HashMap callbacks, - DiscordBuild... preferredOrder) throws NoDiscordClientException - { + public static Pipe openPipe(IPCClient ipcClient, long clientId, HashMap callbacks, + DiscordBuild... preferredOrder) throws NoDiscordClientException { - if(preferredOrder == null || preferredOrder.length == 0) + if (preferredOrder == null || preferredOrder.length == 0) preferredOrder = new DiscordBuild[]{DiscordBuild.ANY}; Pipe pipe = null; // store some files so we can get the preferred client Pipe[] open = new Pipe[DiscordBuild.values().length]; - for(int i = 0; i < 10; i++) - { - try - { - String location = getPipeLocation(i); - LOGGER.debug(String.format("Searching for IPC: %s", location)); - pipe = createPipe(ipcClient, callbacks, location); - - pipe.send(Packet.OpCode.HANDSHAKE, new JSONObject().put("v", VERSION).put("client_id", Long.toString(clientId)), null); - - Packet p = pipe.read(); // this is a valid client at this point - - pipe.build = DiscordBuild.from(p.getJson().getJSONObject("data") - .getJSONObject("config") - .getString("api_endpoint")); - - LOGGER.debug(String.format("Found a valid client (%s) with packet: %s", pipe.build.name(), p.toString())); - // we're done if we found our first choice - if(pipe.build == preferredOrder[0] || DiscordBuild.ANY == preferredOrder[0]) - { - LOGGER.info(String.format("Found preferred client: %s", pipe.build.name())); - break; - } + for (int i = 0; i < 10; i++) { + String location = getPipeLocation(i); + if (ipcClient.isDebugMode()) { + ipcClient.getCurrentLogger(LOGGER).info(String.format("[DEBUG] Searching for IPC Pipe: \"%s\"", location)); + } + + try { + File fileLocation = new File(location.replace("\\", "\\\\")); + if (fileLocation.exists()) { + if (ipcClient.isDebugMode()) { + ipcClient.getCurrentLogger(LOGGER).info(String.format("[DEBUG] Found valid file, attempting connection to IPC: \"%s\"", location)); + } + pipe = createPipe(ipcClient, callbacks, fileLocation); - open[pipe.build.ordinal()] = pipe; // didn't find first choice yet, so store what we have - open[DiscordBuild.ANY.ordinal()] = pipe; // also store in 'any' for use later + JsonObject finalObject = new JsonObject(); - pipe.build = null; - pipe = null; - } - catch(IOException | JSONException ex) - { + finalObject.addProperty("v", VERSION); + finalObject.addProperty("client_id", Long.toString(clientId)); + + pipe.send(Packet.OpCode.HANDSHAKE, finalObject); + + Packet p = pipe.read(); // this is a valid client at this point + + final JsonObject parsedData = p.getJson(); + final JsonObject data = parsedData.getAsJsonObject("data"); + final JsonObject userData = data.getAsJsonObject("user"); + + pipe.build = DiscordBuild.from(data + .getAsJsonObject("config") + .get("api_endpoint").getAsString()); + + pipe.currentUser = new User( + userData.getAsJsonPrimitive("username").getAsString(), + userData.has("global_name") && userData.get("global_name").isJsonPrimitive() ? userData.getAsJsonPrimitive("global_name").getAsString() : null, + userData.has("discriminator") && userData.get("discriminator").isJsonPrimitive() ? userData.getAsJsonPrimitive("discriminator").getAsString() : "0", + Long.parseLong(userData.getAsJsonPrimitive("id").getAsString()), + userData.has("avatar") && userData.get("avatar").isJsonPrimitive() ? userData.getAsJsonPrimitive("avatar").getAsString() : null + ); + + if (ipcClient.isDebugMode()) { + ipcClient.getCurrentLogger(LOGGER).info(String.format("[DEBUG] Found a valid client (%s) with packet: %s", pipe.build.name(), p)); + ipcClient.getCurrentLogger(LOGGER).info(String.format("[DEBUG] Found a valid user (%s) with id: %s", pipe.currentUser.getName(), pipe.currentUser.getId())); + } + + // we're done if we found our first choice + if (pipe.build == preferredOrder[0] || DiscordBuild.ANY == preferredOrder[0]) { + if (ipcClient.isDebugMode()) { + ipcClient.getCurrentLogger(LOGGER).info(String.format("[DEBUG] Found preferred client: %s", pipe.build.name())); + } + break; + } + + open[pipe.build.ordinal()] = pipe; // didn't find first choice yet, so store what we have + open[DiscordBuild.ANY.ordinal()] = pipe; // also store in 'any' for use later + + pipe.build = null; + pipe = null; + } else { + if (ipcClient.isDebugMode()) { + ipcClient.getCurrentLogger(LOGGER).info(String.format("[DEBUG] Unable to locate IPC Pipe: \"%s\"", location)); + } + } + } catch (IOException | JsonParseException ex) { pipe = null; } } - if(pipe == null) - { + if (pipe == null) { // we already know we don't have our first pick // check each of the rest to see if we have that - for(int i = 1; i < preferredOrder.length; i++) - { + for (int i = 1; i < preferredOrder.length; i++) { DiscordBuild cb = preferredOrder[i]; - LOGGER.debug(String.format("Looking for client build: %s", cb.name())); - if(open[cb.ordinal()] != null) - { + if (ipcClient.isDebugMode()) { + ipcClient.getCurrentLogger(LOGGER).info(String.format("[DEBUG] Looking for client build: %s", cb.name())); + } + + if (open[cb.ordinal()] != null) { pipe = open[cb.ordinal()]; open[cb.ordinal()] = null; - if(cb == DiscordBuild.ANY) // if we pulled this from the 'any' slot, we need to figure out which build it was + if (cb == DiscordBuild.ANY) // if we pulled this from the 'any' slot, we need to figure out which build it was { - for(int k = 0; k < open.length; k++) - { - if(open[k] == pipe) - { + for (int k = 0; k < open.length; k++) { + if (open[k] == pipe) { pipe.build = DiscordBuild.values()[k]; open[k] = null; // we don't want to close this } } - } - else pipe.build = cb; + } else pipe.build = cb; - LOGGER.info(String.format("Found preferred client: %s", pipe.build.name())); + if (ipcClient.isDebugMode()) { + ipcClient.getCurrentLogger(LOGGER).info(String.format("[DEBUG] Found preferred client: %s", pipe.build.name())); + } break; } } - if(pipe == null) - { + if (pipe == null) { throw new NoDiscordClientException(); } } - // close unused files, except skip 'any' because its always a duplicate - for(int i = 0; i < open.length; i++) - { - if(i == DiscordBuild.ANY.ordinal()) + // close unused files, except skip 'any' because it's always a duplicate + for (int i = 0; i < open.length; i++) { + if (i == DiscordBuild.ANY.ordinal()) continue; - if(open[i] != null) - { + if (open[i] != null) { try { open[i].close(); - } catch(IOException ex) { + } catch (IOException ex) { // This isn't really important to applications and better // as debug info - LOGGER.debug("Failed to close an open IPC pipe!", ex); + if (ipcClient.isDebugMode()) { + ipcClient.getCurrentLogger(LOGGER).info(String.format("[DEBUG] Failed to close an open IPC pipe: %s", ex)); + } } } } @@ -150,126 +183,154 @@ public static Pipe openPipe(IPCClient ipcClient, long clientId, HashMap callbacks, String location) { + private static Pipe createPipe(IPCClient ipcClient, HashMap callbacks, File location) { String osName = System.getProperty("os.name").toLowerCase(); - if (osName.contains("win")) - { + if (osName.contains("win")) { return new WindowsPipe(ipcClient, callbacks, location); - } - else if (osName.contains("linux") || osName.contains("mac")) - { + } else if (osName.contains("linux") || osName.contains("mac")) { try { - return new UnixPipe(ipcClient, callbacks, location); - } - catch (IOException e) - { - throw new RuntimeException(e); + return osName.contains("mac") ? new MacPipe(ipcClient, callbacks, location) : new UnixPipe(ipcClient, callbacks, location); + } catch (IOException e) { + throw new RuntimeException("Unable to create MacOS/Unix Pipe", e); } - } - else - { + } else { throw new RuntimeException("Unsupported OS: " + osName); } } + /** + * Generates a nonce. + * + * @return A random {@link UUID}. + */ + private static String generateNonce() { + return UUID.randomUUID().toString(); + } + + /** + * Finds the IPC location in the current system. + * + * @param index The index to try getting the IPC at. + * @return The IPC location. + */ + @SuppressWarnings("ConstantConditions") + private static String getPipeLocation(int index) { + String tmpPath = null, pipePath = "discord-ipc-" + index; + if (System.getProperty("os.name").contains("Win")) + return "\\\\?\\pipe\\" + pipePath; + for (String str : unixPaths) { + tmpPath = System.getenv(str); + if (tmpPath != null) + break; + } + if (tmpPath == null) { + tmpPath = "/tmp"; + } + for (String str : unixFolderPaths) { + String folderPath = tmpPath + str; + File folderFile = new File(folderPath); + if (folderFile.exists() && folderFile.isDirectory() && folderFile.list().length > 0) { + tmpPath = folderPath; + break; + } + } + return tmpPath + "/" + pipePath; + } + /** * Sends json with the given {@link Packet.OpCode}. * - * @param op The {@link Packet.OpCode} to send data with. - * @param data The data to send. + * @param op The {@link Packet.OpCode} to send data with. + * @param data The data to send. * @param callback callback for the response */ - public void send(Packet.OpCode op, JSONObject data, Callback callback) - { - try - { + public void send(Packet.OpCode op, JsonObject data, Callback callback) { + try { String nonce = generateNonce(); - Packet p = new Packet(op, data.put("nonce",nonce)); - if(callback!=null && !callback.isEmpty()) + data.addProperty("nonce", nonce); + Packet p = new Packet(op, data, ipcClient.getEncoding()); + if (callback != null && !callback.isEmpty()) callbacks.put(nonce, callback); write(p.toBytes()); - LOGGER.debug(String.format("Sent packet: %s", p.toString())); - if(listener != null) + if (ipcClient.isDebugMode()) { + ipcClient.getCurrentLogger(LOGGER).info(String.format("[DEBUG] Sent packet: %s", p.toDecodedString())); + } + + if (listener != null) listener.onPacketSent(ipcClient, p); - } - catch(IOException ex) - { - LOGGER.error("Encountered an IOException while sending a packet and disconnected!"); + } catch (IOException ex) { + ipcClient.getCurrentLogger(LOGGER).error("Encountered an IOException while sending a packet and disconnected!"); status = PipeStatus.DISCONNECTED; } } /** - * Blocks until reading a {@link Packet} or until the - * read thread encounters bad data. + * Sends json with the given {@link Packet.OpCode}. * - * @return A valid {@link Packet}. + * @param op The {@link Packet.OpCode} to send data with. + * @param data The data to send. + */ + public void send(Packet.OpCode op, JsonObject data) { + send(op, data, null); + } + + /** + * Receives a {@link Packet} with the given {@link Packet.OpCode} and byte data. * - * @throws IOException - * If the pipe breaks. - * @throws JSONException - * If the read thread receives bad data. + * @param op The {@link Packet.OpCode} to receive data with. + * @param data The data to parse with. + * @return the resulting {@link Packet} */ - public abstract Packet read() throws IOException, JSONException; + @SuppressWarnings("deprecation") + public Packet receive(Packet.OpCode op, byte[] data) { + JsonObject packetData = new JsonParser().parse(new String(data)).getAsJsonObject(); + Packet p = new Packet(op, packetData, ipcClient.getEncoding()); - public abstract void write(byte[] b) throws IOException; + if (ipcClient.isDebugMode()) { + ipcClient.getCurrentLogger(LOGGER).info(String.format("[DEBUG] Received packet: %s", p)); + } + + if (listener != null) + listener.onPacketReceived(ipcClient, p); + return p; + } /** - * Generates a nonce. + * Blocks until reading a {@link Packet} or until the + * read thread encounters bad data. * - * @return A random {@link UUID}. + * @return A valid {@link Packet}. + * @throws IOException If the pipe breaks. + * @throws JsonParseException If the read thread receives bad data. */ - private static String generateNonce() - { - return UUID.randomUUID().toString(); - } + public abstract Packet read() throws IOException, JsonParseException; + + public abstract void write(byte[] b) throws IOException; + + public abstract void registerApp(String applicationId, String command); - public PipeStatus getStatus() - { + public abstract void registerSteamGame(String applicationId, String steamId); + + public PipeStatus getStatus() { return status; } - public void setStatus(PipeStatus status) - { + public void setStatus(PipeStatus status) { this.status = status; } - public void setListener(IPCListener listener) - { + public void setListener(IPCListener listener) { this.listener = listener; } public abstract void close() throws IOException; - public DiscordBuild getDiscordBuild() - { + public DiscordBuild getDiscordBuild() { return build; } - // a list of system property keys to get IPC file from different unix systems. - private final static String[] unixPaths = {"XDG_RUNTIME_DIR","TMPDIR","TMP","TEMP"}; - - /** - * Finds the IPC location in the current system. - * - * @param i Index to try getting the IPC at. - * - * @return The IPC location. - */ - private static String getPipeLocation(int i) - { - if(System.getProperty("os.name").contains("Win")) - return "\\\\?\\pipe\\discord-ipc-"+i; - String tmppath = null; - for(String str : unixPaths) - { - tmppath = System.getenv(str); - if(tmppath != null) - break; - } - if(tmppath == null) - tmppath = "/tmp"; - return tmppath+"/discord-ipc-"+i; + public User getCurrentUser() { + return currentUser; } } diff --git a/src/main/java/com/jagrosh/discordipc/entities/pipe/PipeStatus.java b/src/main/java/com/jagrosh/discordipc/entities/pipe/PipeStatus.java index 7f4af25..a322b5d 100644 --- a/src/main/java/com/jagrosh/discordipc/entities/pipe/PipeStatus.java +++ b/src/main/java/com/jagrosh/discordipc/entities/pipe/PipeStatus.java @@ -24,26 +24,25 @@ /** * Constants representing various status that an {@link IPCClient} can have. */ -public enum PipeStatus -{ +public enum PipeStatus { /** - * Status for when the IPCClient when no attempt to connect has been made.

- * + * Status for when the IPCClient when no attempt to connect has been made. + *

* All IPCClients are created starting with this status, * and it never returns for the lifespan of the client. */ UNINITIALIZED, /** - * Status for when the Pipe is attempting to connect.

- * + * Status for when the Pipe is attempting to connect. + *

* This will become set whenever the #connect() method is called. */ CONNECTING, /** - * Status for when the Pipe is connected with Discord.

- * + * Status for when the Pipe is connected with Discord. + *

* This is only present when the connection is healthy, stable, * and reading good data without exception.
* If the environment becomes out of line with these principles @@ -53,8 +52,15 @@ public enum PipeStatus CONNECTED, /** - * Status for when the Pipe has received an {@link Packet.OpCode#CLOSE}.

- * + * Status for when the pipe status is beginning to close. + *

+ * The status that immediately follows is always {@link PipeStatus#CLOSED} + */ + CLOSING, + + /** + * Status for when the Pipe has received an {@link Packet.OpCode#CLOSE}. + *

* This signifies that the reading thread has safely and normally shut * and the client is now inactive. */ @@ -62,12 +68,12 @@ public enum PipeStatus /** * Status for when the Pipe has unexpectedly disconnected, either because - * of an exception, and/or due to bad data.

- * - * When the status of an Pipe becomes this, a call to + * of an exception, and/or due to bad data. + *

+ * When the status of a Pipe becomes this, a call to * {@link IPCListener#onDisconnect(IPCClient, Throwable)} will be made if one - * has been provided to the IPCClient.

- * + * has been provided to the IPCClient. + *

* Note that the IPCClient will be inactive with this status, after which a * call to {@link IPCClient#connect(DiscordBuild...)} can be made to "reconnect" the * IPCClient. diff --git a/src/main/java/com/jagrosh/discordipc/entities/pipe/UnixPipe.java b/src/main/java/com/jagrosh/discordipc/entities/pipe/UnixPipe.java index 22de47e..0eed109 100644 --- a/src/main/java/com/jagrosh/discordipc/entities/pipe/UnixPipe.java +++ b/src/main/java/com/jagrosh/discordipc/entities/pipe/UnixPipe.java @@ -16,89 +16,161 @@ package com.jagrosh.discordipc.entities.pipe; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; import com.jagrosh.discordipc.IPCClient; import com.jagrosh.discordipc.entities.Callback; import com.jagrosh.discordipc.entities.Packet; -import org.json.JSONException; -import org.json.JSONObject; import org.newsclub.net.unix.AFUNIXSocket; import org.newsclub.net.unix.AFUNIXSocketAddress; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; +import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; +import java.nio.file.Files; import java.nio.file.Paths; import java.util.HashMap; -public class UnixPipe extends Pipe -{ - +public class UnixPipe extends Pipe { private static final Logger LOGGER = LoggerFactory.getLogger(UnixPipe.class); private final AFUNIXSocket socket; - UnixPipe(IPCClient ipcClient, HashMap callbacks, String location) throws IOException - { + UnixPipe(IPCClient ipcClient, HashMap callbacks, File location) throws IOException { super(ipcClient, callbacks); socket = AFUNIXSocket.newInstance(); - socket.connect(AFUNIXSocketAddress.of(Paths.get(location))); + socket.connect(AFUNIXSocketAddress.of(location)); } - @SuppressWarnings("ResultOfMethodCallIgnored") @Override - public Packet read() throws IOException, JSONException - { + @SuppressWarnings("BusyWait") + public Packet read() throws IOException, JsonParseException { InputStream is = socket.getInputStream(); - while(is.available() == 0 && status == PipeStatus.CONNECTED) - { + while ((status == PipeStatus.CONNECTED || status == PipeStatus.CLOSING) && is.available() == 0) { try { Thread.sleep(50); - } catch(InterruptedException ignored) {} + } catch (InterruptedException ignored) { + } } - /*byte[] buf = new byte[is.available()]; - is.read(buf, 0, buf.length); - LOGGER.info(new String(buf)); - - if (true) return null;*/ - - if(status==PipeStatus.DISCONNECTED) + if (status == PipeStatus.DISCONNECTED) throw new IOException("Disconnected!"); - if(status==PipeStatus.CLOSED) - return new Packet(Packet.OpCode.CLOSE, null); + if (status == PipeStatus.CLOSED) + return new Packet(Packet.OpCode.CLOSE, null, ipcClient.getEncoding()); // Read the op and length. Both are signed ints byte[] d = new byte[8]; - is.read(d); + int readResult = is.read(d); ByteBuffer bb = ByteBuffer.wrap(d); + if (ipcClient.isDebugMode() && ipcClient.isVerboseLogging()) { + ipcClient.getCurrentLogger(LOGGER).info(String.format("[DEBUG] Read Byte Data: %s with result %s", new String(d), readResult)); + } + Packet.OpCode op = Packet.OpCode.values()[Integer.reverseBytes(bb.getInt())]; d = new byte[Integer.reverseBytes(bb.getInt())]; - is.read(d); - Packet p = new Packet(op, new JSONObject(new String(d))); - LOGGER.debug(String.format("Received packet: %s", p.toString())); - if(listener != null) - listener.onPacketReceived(ipcClient, p); - return p; + int reversedResult = is.read(d); + + if (ipcClient.isDebugMode() && ipcClient.isVerboseLogging()) { + ipcClient.getCurrentLogger(LOGGER).info(String.format("[DEBUG] Read Reversed Byte Data: %s with result %s", new String(d), reversedResult)); + } + + return receive(op, d); } @Override - public void write(byte[] b) throws IOException - { + public void write(byte[] b) throws IOException { socket.getOutputStream().write(b); } @Override - public void close() throws IOException - { - LOGGER.debug("Closing IPC pipe..."); - send(Packet.OpCode.CLOSE, new JSONObject(), null); + public void close() throws IOException { + if (ipcClient.isDebugMode()) { + ipcClient.getCurrentLogger(LOGGER).info("[DEBUG] Closing IPC pipe..."); + } + + status = PipeStatus.CLOSING; + send(Packet.OpCode.CLOSE, new JsonObject()); status = PipeStatus.CLOSED; socket.close(); } + + public boolean mkdir(String path) { + File file = new File(path); + + return file.exists() && file.isDirectory() || file.mkdir(); + } + + @Override + public void registerApp(String applicationId, String command) { + String home = System.getenv("HOME"); + + if (home == null) + throw new RuntimeException("Unable to find user HOME directory"); + + if (command == null) { + try { + command = Files.readSymbolicLink(Paths.get("/proc/self/exe")).toString(); + } catch (Exception ex) { + throw new RuntimeException("Unable to get current exe path from /proc/self/exe", ex); + } + } + + String desktopFile = + "[Desktop Entry]\n" + + "Name=Game " + applicationId + "\n" + + "Exec=" + command + " %%u\n" + + "Type=Application\n" + + "NoDisplay=true\n" + + "Categories=Discord;Games;\n" + + "MimeType=x-scheme-handler/discord-" + applicationId + ";\n"; + + String desktopFileName = "/discord-" + applicationId + ".desktop"; + String desktopFilePath = home + "/.local"; + + if (this.mkdir(desktopFilePath)) + ipcClient.getCurrentLogger(LOGGER).warn("[DEBUG] Failed to create directory '" + desktopFilePath + "', may already exist"); + + desktopFilePath += "/share"; + + if (this.mkdir(desktopFilePath)) + ipcClient.getCurrentLogger(LOGGER).warn("[DEBUG] Failed to create directory '" + desktopFilePath + "', may already exist"); + + desktopFilePath += "/applications"; + + if (this.mkdir(desktopFilePath)) + ipcClient.getCurrentLogger(LOGGER).warn("[DEBUG] Failed to create directory '" + desktopFilePath + "', may already exist"); + + desktopFilePath += desktopFileName; + + try (FileWriter fileWriter = new FileWriter(desktopFilePath)) { + fileWriter.write(desktopFile); + } catch (Exception ex) { + throw new RuntimeException("Failed to write desktop info into '" + desktopFilePath + "'"); + } + + String xdgMimeCommand = "xdg-mime default discord-" + applicationId + ".desktop x-scheme-handler/discord-" + applicationId; + + try { + ProcessBuilder processBuilder = new ProcessBuilder(xdgMimeCommand.split(" ")); + processBuilder.environment(); + int result = processBuilder.start().waitFor(); + if (result < 0) + throw new Exception("xdg-mime returned " + result); + } catch (Exception ex) { + throw new RuntimeException("Failed to register mime handler", ex); + } + } + + @Override + public void registerSteamGame(String applicationId, String steamId) { + this.registerApp(applicationId, "xdg-open steam://rungameid/" + steamId); + } } diff --git a/src/main/java/com/jagrosh/discordipc/entities/pipe/WindowsPipe.java b/src/main/java/com/jagrosh/discordipc/entities/pipe/WindowsPipe.java index c1e9ce2..6faf8cb 100644 --- a/src/main/java/com/jagrosh/discordipc/entities/pipe/WindowsPipe.java +++ b/src/main/java/com/jagrosh/discordipc/entities/pipe/WindowsPipe.java @@ -16,33 +16,34 @@ package com.jagrosh.discordipc.entities.pipe; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; import com.jagrosh.discordipc.IPCClient; import com.jagrosh.discordipc.entities.Callback; import com.jagrosh.discordipc.entities.Packet; -import org.json.JSONException; -import org.json.JSONObject; +import com.jagrosh.discordipc.impl.WinRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.RandomAccessFile; import java.util.HashMap; -public class WindowsPipe extends Pipe -{ - +public class WindowsPipe extends Pipe { private static final Logger LOGGER = LoggerFactory.getLogger(WindowsPipe.class); + private static final Float javaSpec = Float.parseFloat(System.getProperty("java.specification.version")); + private final int targetKey = WinRegistry.HKEY_CURRENT_USER; + private final long targetLongKey = targetKey; + public RandomAccessFile file; - private final RandomAccessFile file; - - WindowsPipe(IPCClient ipcClient, HashMap callbacks, String location) - { + WindowsPipe(IPCClient ipcClient, HashMap callbacks, File location) { super(ipcClient, callbacks); try { this.file = new RandomAccessFile(location, "rw"); } catch (FileNotFoundException e) { - throw new RuntimeException(e); + throw new RuntimeException("Unable to access '" + location + "', check file permissions", e); } } @@ -52,38 +53,113 @@ public void write(byte[] b) throws IOException { } @Override - public Packet read() throws IOException, JSONException { - while(file.length() == 0 && status == PipeStatus.CONNECTED) - { + @SuppressWarnings("BusyWait") + public Packet read() throws IOException, JsonParseException { + while ((status == PipeStatus.CONNECTED || status == PipeStatus.CLOSING) && file.length() == 0) { try { Thread.sleep(50); - } catch(InterruptedException ignored) {} + } catch (InterruptedException ignored) { + } } - if(status==PipeStatus.DISCONNECTED) + if (status == PipeStatus.DISCONNECTED) throw new IOException("Disconnected!"); - if(status==PipeStatus.CLOSED) - return new Packet(Packet.OpCode.CLOSE, null); + if (status == PipeStatus.CLOSED) + return new Packet(Packet.OpCode.CLOSE, null, ipcClient.getEncoding()); Packet.OpCode op = Packet.OpCode.values()[Integer.reverseBytes(file.readInt())]; int len = Integer.reverseBytes(file.readInt()); byte[] d = new byte[len]; file.readFully(d); - Packet p = new Packet(op, new JSONObject(new String(d))); - LOGGER.debug(String.format("Received packet: %s", p.toString())); - if(listener != null) - listener.onPacketReceived(ipcClient, p); - return p; + + return receive(op, d); } @Override public void close() throws IOException { - LOGGER.debug("Closing IPC pipe..."); - send(Packet.OpCode.CLOSE, new JSONObject(), null); + if (ipcClient.isDebugMode()) { + ipcClient.getCurrentLogger(LOGGER).info("[DEBUG] Closing IPC pipe..."); + } + + status = PipeStatus.CLOSING; + send(Packet.OpCode.CLOSE, new JsonObject()); status = PipeStatus.CLOSED; file.close(); } + @SuppressWarnings("DuplicatedCode") + @Override + public void registerApp(String applicationId, String command) { + String javaLibraryPath = System.getProperty("java.home"); + File javaExeFile = new File(javaLibraryPath.split(";")[0] + "/bin/java.exe"); + File javawExeFile = new File(javaLibraryPath.split(";")[0] + "/bin/javaw.exe"); + String javaExePath = javaExeFile.exists() ? javaExeFile.getAbsolutePath() : javawExeFile.exists() ? javawExeFile.getAbsolutePath() : null; + + if (javaExePath == null) + throw new RuntimeException("Unable to find java path"); + + String openCommand; + + if (command != null) + openCommand = command; + else + openCommand = javaExePath; + + String protocolName = "discord-" + applicationId; + String protocolDescription = "URL:Run game " + applicationId + " protocol"; + String keyName = "Software\\Classes\\" + protocolName; + String iconKeyName = keyName + "\\DefaultIcon"; + String commandKeyName = keyName + "\\shell\\open\\command"; + + try { + if (javaSpec >= 11) { + WinRegistry.createKey(targetLongKey, keyName); + WinRegistry.writeStringValue(targetLongKey, keyName, "", protocolDescription); + WinRegistry.writeStringValue(targetLongKey, keyName, "URL Protocol", "\0"); + + WinRegistry.createKey(targetLongKey, iconKeyName); + WinRegistry.writeStringValue(targetLongKey, iconKeyName, "", javaExePath); + + WinRegistry.createKey(targetLongKey, commandKeyName); + WinRegistry.writeStringValue(targetLongKey, commandKeyName, "", openCommand); + } else { + WinRegistry.createKey(targetKey, keyName); + WinRegistry.writeStringValue(targetKey, keyName, "", protocolDescription); + WinRegistry.writeStringValue(targetKey, keyName, "URL Protocol", "\0"); + + WinRegistry.createKey(targetKey, iconKeyName); + WinRegistry.writeStringValue(targetKey, iconKeyName, "", javaExePath); + + WinRegistry.createKey(targetKey, commandKeyName); + WinRegistry.writeStringValue(targetKey, commandKeyName, "", openCommand); + } + } catch (Throwable ex) { + throw new RuntimeException("Unable to modify Discord registry keys", ex); + } + } + + @Override + public void registerSteamGame(String applicationId, String steamId) { + try { + String steamPath; + if (javaSpec >= 11) { + steamPath = WinRegistry.readString(targetLongKey, "Software\\\\Valve\\\\Steam", "SteamExe"); + } else { + steamPath = WinRegistry.readString(targetKey, "Software\\\\Valve\\\\Steam", "SteamExe"); + } + if (steamPath == null) + throw new RuntimeException("Steam exe path not found"); + + steamPath = steamPath.replaceAll("/", "\\"); + + String command = "\"" + steamPath + "\" steam://rungameid/" + steamId; + + this.registerApp(applicationId, command); + } catch (Exception ex) { + throw new RuntimeException("Unable to register Steam game", ex); + } + } + } diff --git a/src/main/java/com/jagrosh/discordipc/exceptions/NoDiscordClientException.java b/src/main/java/com/jagrosh/discordipc/exceptions/NoDiscordClientException.java index 60cfa8a..c21e9c8 100644 --- a/src/main/java/com/jagrosh/discordipc/exceptions/NoDiscordClientException.java +++ b/src/main/java/com/jagrosh/discordipc/exceptions/NoDiscordClientException.java @@ -13,21 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package com.jagrosh.discordipc.exceptions; +import com.jagrosh.discordipc.IPCClient; import com.jagrosh.discordipc.entities.DiscordBuild; /** - * An exception thrown when an {@link com.jagrosh.discordipc.IPCClient IPCClient} - * when the client cannot find the proper application to use for RichPresence when - * attempting to {@link com.jagrosh.discordipc.IPCClient#connect(DiscordBuild...) connect}.

- * + * An exception thrown when the {@link IPCClient IPCClient} + * cannot find the proper application to use for RichPresence when + * attempting to {@link IPCClient#connect(DiscordBuild...) connect}. + *

* This purely and always means the IPCClient in question (specifically the client ID) * is invalid and features using this library cannot be accessed using the instance. * * @author John Grosh (john.a.grosh@gmail.com) */ -public class NoDiscordClientException extends Exception -{ - +public class NoDiscordClientException extends Exception { + /** + * The serialized unique version identifier + */ + private static final long serialVersionUID = 1L; + + public NoDiscordClientException() { + super("No Valid Discord Client was found for this Instance"); + } } diff --git a/src/main/java/com/jagrosh/discordipc/impl/Backoff.java b/src/main/java/com/jagrosh/discordipc/impl/Backoff.java new file mode 100644 index 0000000..70472f4 --- /dev/null +++ b/src/main/java/com/jagrosh/discordipc/impl/Backoff.java @@ -0,0 +1,35 @@ +package com.jagrosh.discordipc.impl; + +import java.util.Random; + +public class Backoff { + private final long minAmount; + private final long maxAmount; + private final Random randGenerator; + private long current; + private int fails; + + public Backoff(long min, long max) { + this.minAmount = min; + this.maxAmount = max; + this.current = min; + this.fails = 0; + this.randGenerator = new Random(); + } + + public void reset() { + fails = 0; + current = minAmount; + } + + public long nextDelay() { + fails++; + double delay = current * 2.0 * rand01(); + current = Math.min(current + (long) delay, maxAmount); + return current; + } + + private double rand01() { + return randGenerator.nextDouble(); + } +} diff --git a/src/main/java/com/jagrosh/discordipc/impl/WinRegistry.java b/src/main/java/com/jagrosh/discordipc/impl/WinRegistry.java new file mode 100644 index 0000000..ab9667e --- /dev/null +++ b/src/main/java/com/jagrosh/discordipc/impl/WinRegistry.java @@ -0,0 +1,484 @@ +/* + * Copyright 2017 John Grosh (john.a.grosh@gmail.com). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.jagrosh.discordipc.impl; + +import net.lenni0451.reflect.Methods; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.prefs.Preferences; + +@SuppressWarnings("NegativeIntConstantInLongContext") +public class WinRegistry { + public static final int HKEY_CURRENT_USER = 0x80000001; + public static final int HKEY_LOCAL_MACHINE = 0x80000002; + public static final int REG_SUCCESS = 0; + + private static final int KEY_ALL_ACCESS = 0xf003f; + private static final int KEY_READ = 0x20019; + private static final Preferences userRoot = Preferences.userRoot(); + private static final Preferences systemRoot = Preferences.systemRoot(); + private static final Class userClass = userRoot.getClass(); + private static final Method regOpenKey; + private static final Method regCloseKey; + private static final Method regQueryValueEx; + private static final Method regEnumValue; + private static final Method regQueryInfoKey; + private static final Method regEnumKeyEx; + private static final Method regCreateKeyEx; + private static final Method regSetValueEx; + private static final Method regDeleteKey; + private static final Method regDeleteValue; + + private static final float javaSpec; + + static { + try { + javaSpec = Float.parseFloat(System.getProperty("java.specification.version")); + + regOpenKey = Methods.getDeclaredMethod(userClass, "WindowsRegOpenKey", + (javaSpec >= 11 ? long.class : int.class), byte[].class, int.class); + regCloseKey = Methods.getDeclaredMethod(userClass, "WindowsRegCloseKey", + (javaSpec >= 11 ? long.class : int.class)); + regQueryValueEx = Methods.getDeclaredMethod(userClass, "WindowsRegQueryValueEx", + (javaSpec >= 11 ? long.class : int.class), byte[].class); + regEnumValue = Methods.getDeclaredMethod(userClass, "WindowsRegEnumValue", + (javaSpec >= 11 ? long.class : int.class), int.class, int.class); + regQueryInfoKey = Methods.getDeclaredMethod(userClass, "WindowsRegQueryInfoKey1", + (javaSpec >= 11 ? long.class : int.class)); + regEnumKeyEx = Methods.getDeclaredMethod(userClass, + "WindowsRegEnumKeyEx", (javaSpec >= 11 ? long.class : int.class), int.class, + int.class); + regCreateKeyEx = Methods.getDeclaredMethod(userClass, + "WindowsRegCreateKeyEx", (javaSpec >= 11 ? long.class : int.class), + byte[].class); + regSetValueEx = Methods.getDeclaredMethod(userClass, + "WindowsRegSetValueEx", (javaSpec >= 11 ? long.class : int.class), + byte[].class, byte[].class); + regDeleteValue = Methods.getDeclaredMethod(userClass, + "WindowsRegDeleteValue", (javaSpec >= 11 ? long.class : int.class), + byte[].class); + regDeleteKey = Methods.getDeclaredMethod(userClass, + "WindowsRegDeleteKey", (javaSpec >= 11 ? long.class : int.class), + byte[].class); + } catch (Exception e) { + throw new RuntimeException("Unable to setup registry data", e); + } + } + + private WinRegistry() { + } + + /** + * Read a value from key and value name + * + * @param hkey HKEY_CURRENT_USER/HKEY_LOCAL_MACHINE + * @param key The target key + * @param valueName The target value name + * @return the value + * @throws IllegalArgumentException if hkey is invalid + * @throws IllegalAccessException if permissions insufficient + * @throws InvocationTargetException if underlying method(s) throw(s) an exception + */ + public static String readString(int hkey, String key, String valueName) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + if (hkey == HKEY_LOCAL_MACHINE) { + return readString(systemRoot, hkey, key, valueName); + } else if (hkey == HKEY_CURRENT_USER) { + return readString(userRoot, hkey, key, valueName); + } else { + throw new IllegalArgumentException("hkey=" + hkey); + } + } + + /** + * Read a value from key and value name + * + * @param hkey HKEY_CURRENT_USER/HKEY_LOCAL_MACHINE + * @param key The target key + * @param valueName The target value name + * @return the value + * @throws IllegalArgumentException if hkey is invalid + * @throws IllegalAccessException if permissions insufficient + * @throws InvocationTargetException if underlying method(s) throw(s) an exception + */ + public static String readString(long hkey, String key, String valueName) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + if (hkey == HKEY_LOCAL_MACHINE) { + return readString(systemRoot, hkey, key, valueName); + } else if (hkey == HKEY_CURRENT_USER) { + return readString(userRoot, hkey, key, valueName); + } else { + throw new IllegalArgumentException("hkey=" + hkey); + } + } + + /** + * Read value(s) and value name(s) form given key + * + * @param hkey HKEY_CURRENT_USER/HKEY_LOCAL_MACHINE + * @param key The target key + * @return the value name(s) plus the value(s) + * @throws IllegalArgumentException if hkey is invalid + * @throws IllegalAccessException if permissions insufficient + * @throws InvocationTargetException if underlying method(s) throw(s) an exception + */ + public static Map readStringValues(int hkey, String key) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + if (hkey == HKEY_LOCAL_MACHINE) { + return readStringValues(systemRoot, hkey, key); + } else if (hkey == HKEY_CURRENT_USER) { + return readStringValues(userRoot, hkey, key); + } else { + throw new IllegalArgumentException("hkey=" + hkey); + } + } + + /** + * Read the value name(s) from a given key + * + * @param hkey HKEY_CURRENT_USER/HKEY_LOCAL_MACHINE + * @param key The target key + * @return the value name(s) + * @throws IllegalArgumentException if hkey is invalid + * @throws IllegalAccessException if permissions insufficient + * @throws InvocationTargetException if underlying method(s) throw(s) an exception + */ + public static List readStringSubKeys(int hkey, String key) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + if (hkey == HKEY_LOCAL_MACHINE) { + return readStringSubKeys(systemRoot, hkey, key); + } else if (hkey == HKEY_CURRENT_USER) { + return readStringSubKeys(userRoot, hkey, key); + } else { + throw new IllegalArgumentException("hkey=" + hkey); + } + } + + /** + * Create a key + * + * @param hkey HKEY_CURRENT_USER/HKEY_LOCAL_MACHINE + * @param key The target key + * @throws IllegalArgumentException if hkey is invalid + * @throws IllegalAccessException if permissions insufficient + * @throws InvocationTargetException if underlying method(s) throw an exception + */ + public static void createKey(int hkey, String key) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + int[] ret; + if (hkey == HKEY_LOCAL_MACHINE) { + ret = createKey(systemRoot, hkey, key); + Methods.invoke(systemRoot, regCloseKey, ret[0]); + } else if (hkey == HKEY_CURRENT_USER) { + ret = createKey(userRoot, hkey, key); + Methods.invoke(userRoot, regCloseKey, ret[0]); + } else { + throw new IllegalArgumentException("hkey=" + hkey); + } + if (ret[1] != REG_SUCCESS) { + throw new IllegalArgumentException("rc=" + ret[1] + " key=" + key); + } + } + + /** + * Create a key + * + * @param hkey HKEY_CURRENT_USER/HKEY_LOCAL_MACHINE + * @param key The target key + * @throws IllegalArgumentException if hkey is invalid + * @throws IllegalAccessException if permissions insufficient + * @throws InvocationTargetException if underlying method(s) throw an exception + */ + public static void createKey(long hkey, String key) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + long[] ret; + if (hkey == HKEY_LOCAL_MACHINE) { + ret = createKey(systemRoot, hkey, key); + Methods.invoke(systemRoot, regCloseKey, ret[0]); + } else if (hkey == HKEY_CURRENT_USER) { + ret = createKey(userRoot, hkey, key); + Methods.invoke(userRoot, regCloseKey, ret[0]); + } else { + throw new IllegalArgumentException("hkey=" + hkey); + } + if (ret[1] != REG_SUCCESS) { + throw new IllegalArgumentException("rc=" + ret[1] + " key=" + key); + } + } + + /** + * Write a value in a given key/value name + * + * @param hkey HKEY_CURRENT_USER/HKEY_LOCAL_MACHINE + * @param key The target key + * @param valueName The target value name + * @param value The target value + * @throws IllegalArgumentException if hkey is invalid + * @throws IllegalAccessException if permissions insufficient + * @throws InvocationTargetException if underlying method(s) throw an exception + */ + public static void writeStringValue + (int hkey, String key, String valueName, String value) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + if (hkey == HKEY_LOCAL_MACHINE) { + writeStringValue(systemRoot, hkey, key, valueName, value); + } else if (hkey == HKEY_CURRENT_USER) { + writeStringValue(userRoot, hkey, key, valueName, value); + } else { + throw new IllegalArgumentException("hkey=" + hkey); + } + } + + /** + * Write a value in a given key/value name + * + * @param hkey HKEY_CURRENT_USER/HKEY_LOCAL_MACHINE + * @param key The target key + * @param valueName The target value name + * @param value The target value + * @throws IllegalArgumentException if hkey is invalid + * @throws IllegalAccessException if permissions insufficient + * @throws InvocationTargetException if underlying method(s) throw an exception + */ + public static void writeStringValue + (long hkey, String key, String valueName, String value) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + if (hkey == HKEY_LOCAL_MACHINE) { + writeStringValue(systemRoot, hkey, key, valueName, value); + } else if (hkey == HKEY_CURRENT_USER) { + writeStringValue(userRoot, hkey, key, valueName, value); + } else { + throw new IllegalArgumentException("hkey=" + hkey); + } + } + + /** + * Delete a given key + * + * @param hkey HKEY_CURRENT_USER/HKEY_LOCAL_MACHINE + * @param key The target key + * @throws IllegalArgumentException if hkey is invalid + * @throws IllegalAccessException if permissions insufficient + * @throws InvocationTargetException if underlying method(s) throw an exception + */ + public static void deleteKey(int hkey, String key) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + int rc = -1; + if (hkey == HKEY_LOCAL_MACHINE) { + rc = deleteKey(systemRoot, hkey, key); + } else if (hkey == HKEY_CURRENT_USER) { + rc = deleteKey(userRoot, hkey, key); + } + if (rc != REG_SUCCESS) { + throw new IllegalArgumentException("rc=" + rc + " key=" + key); + } + } + + /** + * delete a value from a given key/value name + * + * @param hkey HKEY_CURRENT_USER/HKEY_LOCAL_MACHINE + * @param key The target key + * @param value The target value + * @throws IllegalArgumentException if hkey is invalid + * @throws IllegalAccessException if permissions insufficient + * @throws InvocationTargetException if underlying method(s) throw an exception + */ + public static void deleteValue(int hkey, String key, String value) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + int rc = -1; + if (hkey == HKEY_LOCAL_MACHINE) { + rc = deleteValue(systemRoot, hkey, key, value); + } else if (hkey == HKEY_CURRENT_USER) { + rc = deleteValue(userRoot, hkey, key, value); + } + if (rc != REG_SUCCESS) { + throw new IllegalArgumentException("rc=" + rc + " key=" + key + " value=" + value); + } + } + + // ===================== + + private static int deleteValue + (Preferences root, int hkey, String key, String value) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + int[] handles = Methods.invoke(root, regOpenKey, new Object[]{ + hkey, toCstr(key), KEY_ALL_ACCESS}); + if (handles[1] != REG_SUCCESS) { + return handles[1]; // can be REG_NOTFOUND, REG_ACCESSDENIED + } + int rc = Methods.invoke(root, regDeleteValue, + new Object[]{ + handles[0], toCstr(value) + }); + Methods.invoke(root, regCloseKey, handles[0]); + return rc; + } + + private static int deleteKey(Preferences root, int hkey, String key) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + return Methods.invoke(root, regDeleteKey, + new Object[]{hkey, toCstr(key)}); // can REG_NOTFOUND, REG_ACCESSDENIED, REG_SUCCESS + } + + private static String readString(Preferences root, int hkey, String key, String value) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + int[] handles = Methods.invoke(root, regOpenKey, new Object[]{ + hkey, toCstr(key), KEY_READ}); + if (handles[1] != REG_SUCCESS) { + return null; + } + byte[] valb = Methods.invoke(root, regQueryValueEx, new Object[]{ + handles[0], toCstr(value)}); + Methods.invoke(root, regCloseKey, handles[0]); + return (valb != null ? new String(valb).trim() : null); + } + + private static String readString(Preferences root, long hkey, String key, String value) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + long[] handles = Methods.invoke(root, regOpenKey, new Object[]{ + hkey, toCstr(key), KEY_READ}); + if (handles[1] != REG_SUCCESS) { + return null; + } + byte[] valb = Methods.invoke(root, regQueryValueEx, new Object[]{ + handles[0], toCstr(value)}); + Methods.invoke(root, regCloseKey, handles[0]); + return (valb != null ? new String(valb).trim() : null); + } + + @SuppressWarnings("DuplicatedCode") + private static Map readStringValues + (Preferences root, int hkey, String key) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + HashMap results = new HashMap<>(); + int[] handles = Methods.invoke(root, regOpenKey, new Object[]{ + hkey, toCstr(key), KEY_READ}); + if (handles[1] != REG_SUCCESS) { + return null; + } + int[] info = Methods.invoke(root, regQueryInfoKey, + new Object[]{handles[0]}); + + int count = info[0]; // count + int maxlen = info[3]; // value length max + for (int index = 0; index < count; index++) { + byte[] name = Methods.invoke(root, regEnumValue, new Object[]{ + handles[0], index, maxlen + 1}); + String value = readString(hkey, key, new String(name)); + results.put(new String(name).trim(), value); + } + Methods.invoke(root, regCloseKey, handles[0]); + return results; + } + + @SuppressWarnings("DuplicatedCode") + private static List readStringSubKeys + (Preferences root, int hkey, String key) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + List results = new ArrayList<>(); + int[] handles = Methods.invoke(root, regOpenKey, new Object[]{ + hkey, toCstr(key), KEY_READ + }); + if (handles[1] != REG_SUCCESS) { + return null; + } + int[] info = Methods.invoke(root, regQueryInfoKey, + new Object[]{handles[0]}); + + int count = info[0]; // Fix: info[2] was being used here with wrong results. Suggested by davenpcj, confirmed by Petrucio + int maxlen = info[3]; // value length max + for (int index = 0; index < count; index++) { + byte[] name = Methods.invoke(root, regEnumKeyEx, new Object[]{ + handles[0], index, maxlen + 1 + }); + results.add(new String(name).trim()); + } + Methods.invoke(root, regCloseKey, handles[0]); + return results; + } + + private static int[] createKey(Preferences root, int hkey, String key) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + return Methods.invoke(root, regCreateKeyEx, + new Object[]{hkey, toCstr(key)}); + } + + private static long[] createKey(Preferences root, long hkey, String key) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + return Methods.invoke(root, regCreateKeyEx, + new Object[]{hkey, toCstr(key)}); + } + + private static void writeStringValue + (Preferences root, int hkey, String key, String valueName, String value) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + int[] handles = Methods.invoke(root, regOpenKey, new Object[]{ + hkey, toCstr(key), KEY_ALL_ACCESS}); + + Methods.invoke(root, regSetValueEx, + handles[0], toCstr(valueName), toCstr(value)); + Methods.invoke(root, regCloseKey, handles[0]); + } + + private static void writeStringValue + (Preferences root, long hkey, String key, String valueName, String value) + throws IllegalArgumentException, IllegalAccessException, + InvocationTargetException { + long[] handles = Methods.invoke(root, regOpenKey, new Object[]{ + hkey, toCstr(key), KEY_ALL_ACCESS}); + + Methods.invoke(root, regSetValueEx, + handles[0], toCstr(valueName), toCstr(value)); + Methods.invoke(root, regCloseKey, handles[0]); + } + + // utility + private static byte[] toCstr(String str) { + byte[] result = new byte[str.length() + 1]; + + for (int i = 0; i < str.length(); i++) { + result[i] = (byte) str.charAt(i); + } + result[str.length()] = 0; + return result; + } +}