package org.ch3ck3r.jgbx;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import org.apache.log4j.BasicConfigurator;
import org.apache.log4j.Logger;
import org.apache.log4j.PropertyConfigurator;
import org.apache.log4j.helpers.NullEnumeration;
import org.ch3ck3r.jgbx.callbacks.GBXCallback;
import org.ch3ck3r.jgbx.error.JGBXException;
import org.ch3ck3r.jgbx.internal.JGBXFuture;
import org.ch3ck3r.jgbx.internal.RequestContainer;
import org.ch3ck3r.jgbx.requests.EnableCallbacksRequest;
import org.ch3ck3r.jgbx.requests.Request;
import org.ch3ck3r.jgbx.responses.Response;
import org.ch3ck3r.jgbx.utils.ByteOrderUtils;
/**
* The Java GBX Connector is a library to connect to your ManiaPlanet Server's GBX API (Trackmania2, Questmania,
* Shootmania). The GBX API of your ManiaPlanet Server enables you to communicate and control your server or to develop
* any kind of plugin for your game.
*
* @see <a href="https://bitbucket.org/Ch3ck3r/jgbx/overview">Visit us at BitBucket</a>
* @see <a href="https://bitbucket.org/Ch3ck3r/jgbx/wiki/Home">See the full documentation in our wiki</a>
* @author Frank Zechert
* @version 1
*/
public class JGBXConnector {
/**
* The logger
*/
private static final Logger logger = Logger.getLogger(JGBXConnector.class);
static {
/*
* Set up the basic configuration for log4j.
*/
if (Logger.getRootLogger().getAllAppenders() instanceof NullEnumeration) {
if (new File("etc/log4j.properties").exists()) {
PropertyConfigurator.configure("etc/log4j.properties");
}
else if (new File("etc/log4j.xml").exists()) {
PropertyConfigurator.configure("etc/log4j.xml");
}
else {
BasicConfigurator.configure();
logger.warn("Log4J is not configured on your system. A default configuration is used.");
logger.warn("To get rid of this message configure Log4J by providing a log4j.properties file.");
}
}
}
/**
* The length (in bytes) of the message length header.
*/
private static final Short MESSAGE_LENGTH_HEADER_LENGTH = 4;
/**
* The length (in bytes) of the message handle header.
*/
private static final Short MESSAGE_HANDLE_HEADER_LENGTH = 4;
/**
* The expected handshake answer from the server.
*/
private static final String HANDSHAKE_ANSWER = "GBXRemote 2";
/**
* Size of the request queue.
*/
private static final Integer REQUEST_QUEUE_SIZE = 100;
/**
* Size of the response queue.
*/
private static final Integer RESPONSE_QUEUE_SIZE = 100;
/**
* The host to connect to.
*/
private final InetAddress host;
/**
* The port to connect to
*/
private final Integer port;
/**
* The connection timeout to use.
*/
private final Integer connectionTimeout;
/**
* The timeout for receiving and sending data.
*/
private Integer timeout;
/**
* The socket to connect to.
*/
private final Socket socket;
/**
* Whether callbacks are enabled or not.
*/
private boolean callbacksEnabled;
/**
* The input stream to receive data from the server.
*/
private final InputStream inputStream;
/**
* The output stream to send data to the server.
*/
private final OutputStream outputStream;
/**
* Whether there is an active connection or not.
*/
private boolean isConnected;
/**
* Thread to send requests.
*/
private final SenderThread senderThread;
/**
* Thread to receive responses and callbacks.
*/
private final ReceiverThread receiverThread;
/**
* The next free message handle.
*/
private Integer nextMessageHandle;
/**
* Lock to synchronize access to the message handle.
*/
private final Object messageHandleLock;
/**
* The request queue.
*/
private final ArrayBlockingQueue<RequestContainer> requestQueue;
/**
* The response queue.
*/
private final ConcurrentHashMap<Integer, JGBXFuture<? extends Response>> responseQueue;
/**
* Connect to the given host or IP address. Use the default port 5000 and a default connection timeout of 5 seconds.
*
* @param host
* The host name or IP address to connect to.
* @throws IOException
* The connection to the server failed.
*/
public JGBXConnector(final String host) throws IOException {
this(InetAddress.getByName(host));
}
/**
* Connect to the given host or IP address on the given port. Use a default connection timeout of 5 seconds.
*
* @param host
* The host name or IP address to connect to.
* @param port
* The port to connect to.
* @throws IOException
* The connection to the server failed.
*/
public JGBXConnector(final String host, final Integer port) throws IOException {
this(InetAddress.getByName(host), port);
}
/**
* Connect to the given host or IP address on the given port and a specified timeout value.
*
* @param host
* The host or IP address to connect to.
* @param port
* The port to connect to.
* @param connectionTimeout
* The connection timeout in milliseconds.
* @throws IOException
* The connection to the server failed.
*/
public JGBXConnector(final String host, final Integer port, final Integer connectionTimeout) throws IOException {
this(InetAddress.getByName(host), port, connectionTimeout);
}
/**
* Connect to the given host or IP address. Use the default port 5000 and a default connection timeout of 5 seconds.
*
* @param host
* The host name or IP address to connect to.
* @throws IOException
* The connection to the server failed.
*/
public JGBXConnector(final InetAddress host) throws IOException {
this(host, 5000);
}
/**
* Connect to the given host or IP address on the given port. Use a default connection timeout of 5 seconds.
*
* @param host
* The host name or IP address to connect to.
* @param port
* The port to connect to.
* @throws IOException
* The connection to the server failed.
*/
public JGBXConnector(final InetAddress host, final Integer port) throws IOException {
this(host, port, 5000);
}
/**
* Connect to the given host or IP address on the given port and a specified timeout value.
*
* @param host
* The host or IP address to connect to.
* @param port
* The port to connect to.
* @param connectionTimeout
* The connection timeout in milliseconds.
* @throws IOException
* The connection to the server failed.
*/
public JGBXConnector(final InetAddress host, final Integer port, final Integer connectionTimeout)
throws IOException {
logger.debug("Creating a new JGBXConnector to " + host + ":" + port + " (" + connectionTimeout + "ms timeout)");
this.callbacksEnabled = false;
this.isConnected = false;
this.timeout = 5000;
this.nextMessageHandle = Integer.MIN_VALUE;
this.messageHandleLock = new Object();
this.requestQueue = new ArrayBlockingQueue<>(REQUEST_QUEUE_SIZE);
this.responseQueue = new ConcurrentHashMap<>(RESPONSE_QUEUE_SIZE);
this.host = host;
this.port = port;
this.connectionTimeout = connectionTimeout;
try {
this.socket = new Socket();
this.socket.connect(new InetSocketAddress(host, port), connectionTimeout);
logger.debug("The socket was connected successfully");
this.inputStream = this.socket.getInputStream();
this.outputStream = this.socket.getOutputStream();
this.doHandshake();
}
catch (final IOException e) {
logger.error("Failed to connect to the server.", e);
throw e;
}
logger.info("Successfully connected to a ManiaPlanet server at " + host + ":" + port);
this.isConnected = true;
this.setTimeout(this.timeout);
this.senderThread = new SenderThread(this, this.outputStream, this.requestQueue);
this.receiverThread = new ReceiverThread(this, this.inputStream, this.responseQueue);
this.senderThread.start();
this.receiverThread.start();
}
/**
* Sets the timeout after which the connection is considered lost when an incomplete message has been received.
* The timeout is at 5 seconds by default.
*
* @param timeout
* The timeout in millisecond.
*/
public void setTimeout(final Integer timeout) {
if (this.socket == null) {
final IllegalStateException ise = new IllegalStateException(
"You can not call this method before the socket object is initialized.");
logger.debug("Setting the SO timeout is not possible on a null socket", ise);
throw ise;
}
this.timeout = timeout;
try {
this.socket.setSoTimeout(timeout);
logger.debug("Socket SO timeout set to " + timeout + "ms");
}
catch (final SocketException e) {
logger.error("Setting the socket SO timeout failed, disconnecting");
this.disconnect();
}
}
/**
* Returns whether there is an active connection to the ManiaPlanet server.
*
* @return Returns true if the connection is established, false otherwise.
*/
public boolean isConnected() {
return this.isConnected;
}
/**
* Disconnects from the ManiaPlanet server. If there is no established connection this method will do nothing.
*/
public void disconnect() {
if (!this.isConnected) {
return;
}
this.senderThread.exit();
this.receiverThread.exit();
this.isConnected = false;
this.requestQueue.clear();
for (final JGBXFuture<?> futureResponse : this.responseQueue.values()) {
futureResponse.fail(new JGBXException("Cancelled because JGBXConnector disconnected."));
}
this.responseQueue.clear();
try {
this.socket.close();
logger.info("Disconnected from the ManiaPlanet Server at " + this.host + ":" + this.port);
}
catch (final IOException e) {
// we can nothing do here but ignore the error
logger.warn("Disconnecting from the ManiaPlanet Server failed", e);
}
}
/**
* Returns the IP address connected to or tried to connect to if connection failed.
*
* @return The IP address in textual representation.
*/
public String getHost() {
return this.host.getHostAddress();
}
/**
* Return the InetAddress connected to or tried to connect to if connection failed.
*
* @return The InetAddress object representing the host.
*/
public InetAddress getHostAddress() {
return this.host;
}
/**
* Return the port connected to or tried to connect to if connection failed.
*
* @return The port.
*/
public Integer getPort() {
return this.port;
}
/**
* Returns the connection timeout used.
*
* @return The connection timeout in milliseconds.
*/
public Integer getConnectionTimeout() {
return this.connectionTimeout;
}
/**
* Returns whether callbacks are enabled or not
*
* @return true if callbacks are enabled, false otherwise
*/
public boolean isCallbacksEnabled() {
return this.callbacksEnabled;
}
/**
* Enable callbacks. This is equivalent to sending EnableCallbackRequest with true as a parameter.
* If callbacks are already enabled this method does nothing.
*
* @throws JGBXException
* failed to enable callbacks.
*/
public void enabledCallbacks() throws JGBXException {
if (this.callbacksEnabled) {
// callbacks are already enabled, do nothing
return;
}
this.syncQuery(new EnableCallbacksRequest(true));
this.callbacksEnabled = true;
}
/**
* Disable callbacks. This is equivalent to sending EnableCallbackRequest with false as a parameter.
* If callbacks are already disabled this method does nothing.
*
* @throws JGBXException
* failed to disable callbacks.
*/
public void disableCallbacks() throws JGBXException {
if (!this.callbacksEnabled) {
// callbacks are already disabled, do nothing
return;
}
this.syncQuery(new EnableCallbacksRequest(false));
this.callbacksEnabled = false;
}
/**
* Sends an asynchronous request to the server and returns a response of type T.
* The response is returned immediately as a future object. This means that this
* method call will not block. The future object will have a value as soon as the server sends a response.
*
* @param request
* The request to send.
* @return The future object holding the response of type T.
* @throws JGBXException
* Your request failed.
*/
public <T extends Response> Future<T> asyncQuery(final Request request) throws JGBXException {
final JGBXFuture<T> future = new JGBXFuture<T>(request.getResponseClass());
this.addRequest(request, future);
return future;
}
/**
* Sends a synchronous request to the server and returns a response of type T.
* The method call will block until the response is available.
*
* @param request
* The request to send.
* @return The response sent by the server.
* @throws JGBXException
* Your request failed.
*/
public <T extends Response> T syncQuery(final Request request) throws JGBXException {
final Future<T> response = this.<T> asyncQuery(request);
try {
return response.get();
}
catch (InterruptedException | ExecutionException e) {
throw new JGBXException(e);
}
}
/**
* Add an event listener that is called when an event is received.
*
* @param listener
* The object to call.
*/
public void addEventListener(final GBXCallback listener) {
}
/**
* Remove an event listener.
*
* @param listener
* The event listener to remove
*/
public void removeEventListener(final GBXCallback listener) {
}
/**
* Perform a handshake with the connected server.
* The handshake will validate that the connected server is
* a ManiaPlanet server and not a server of a different kind.
*
* @throws IOException
* The handshake failed.
*/
private void doHandshake() throws IOException {
this.socket.setSoTimeout(this.connectionTimeout);
final Integer length = this.getNextMessageLenght();
if (length == -1) {
throw new IOException(
"Failed to do a handshake with the given server. No data was received from the connected peer.");
}
final String protocol = this.getNextMessage(length);
logger.debug("Received handshake of length " + length + ": " + protocol);
if (!protocol.equals(HANDSHAKE_ANSWER)) {
logger.debug("The received handshake is invalid.");
throw new IOException(
"Connected server does not support the correct protocol. Ist his a ManiaPlanet server?");
}
}
/**
* Tries to read the next message length and returns it.
* To get the next message length this method needs to read 4 bytes from the
* socket stream. For reading the bytes the set SO timeout is used.
* If reading the first byte fails (no data is available on the stream) no
* message can be read from the stream. A message length of -1 is returned
* by the function.
* If reading the first byte succeeded but the timeout is hit for the following
* bytes a connection problem is the cause. The method will then throw a
* SocketTimeoutException or an IOException.
*
* @return the length of the next message
* @throws SocketTimeoutException
* The length of the next message could not be read in time.
* @throws IOException
* There was an error while reading from the socket.
*/
Integer getNextMessageLenght() throws SocketTimeoutException, IOException {
return this.getNextMessageLenght(this.timeout);
}
/**
* Tries to read the next message length and returns it.
* To get the next message length this method needs to read 4 bytes from the
* socket stream. For reading the first byte the given timeout is used. For
* all following bytes the set SO timeout is used.
* If reading the first byte fails (no data is available on the stream) no
* message can be read from the stream. A message length of -1 is returned
* by the function.
* If reading the first byte succeeded but the timeout is hit for the following
* bytes a connection problem is the cause. The method will then throw a
* SocketTimeoutException or an IOException.
*
* @param timeout
* timeout in milliseconds to wait for first byte
* @return the length of the next message
* @throws SocketTimeoutException
* The length of the next message could not be read in time.
* @throws IOException
* There was an error while reading from the socket.
*/
Integer getNextMessageLenght(final int timeout) throws SocketTimeoutException, IOException {
final byte[] data = new byte[MESSAGE_LENGTH_HEADER_LENGTH];
Integer read = 0;
boolean timeoutReset = false;
this.socket.setSoTimeout(timeout);
try {
do {
read += this.inputStream.read(data, read, MESSAGE_LENGTH_HEADER_LENGTH - read);
if (read > 0 && !timeoutReset) {
this.socket.setSoTimeout(this.timeout);
timeoutReset = true;
}
}
while (read < MESSAGE_LENGTH_HEADER_LENGTH);
}
catch (final SocketTimeoutException e) {
if (!timeoutReset) {
this.socket.setSoTimeout(this.timeout);
}
if (read == 0) {
return -1;
}
throw e;
}
if (!timeoutReset) {
this.socket.setSoTimeout(this.timeout);
}
final Integer length = ByteOrderUtils.littleEndianessToInteger(data);
if (length < 0) {
throw new IOException("Received message was too long: " + length);
}
return length;
}
/**
* Tries to read the next message handle and returns it.
* If there is nothing to read, a TimeoutException is thrown after the SO timeout is reached.
*
* @return the handle of the next message
* @throws SocketTimeoutException
* The handle of the next message could not be read in time.
* @throws IOException
* There was an error while reading from the socket.
*/
Integer getNextMessageHandle() throws SocketTimeoutException, IOException {
final byte[] data = new byte[MESSAGE_HANDLE_HEADER_LENGTH];
Integer read = 0;
do {
read += this.inputStream.read(data, read, MESSAGE_HANDLE_HEADER_LENGTH - read);
}
while (read < MESSAGE_HANDLE_HEADER_LENGTH);
return ByteOrderUtils.littleEndianessToInteger(data);
}
/**
* Reads the next message of the given length and returns it.
* The message is decoded using the UTF-8 encoding. If the message can not
* be read within the SO timeout an IOException is thrown.
*
* @param length
* The length of the message to read.
* @return The XML-RPC message that was received.
* @throws IOException
* Could not receive the message.
*/
String getNextMessage(final Integer length) throws IOException {
final byte[] data = new byte[length];
Integer read = 0;
do {
read += this.inputStream.read(data, read, length - read);
}
while (read < length);
return new String(data, "UTF-8");
}
/**
* Add a request to the queue
*
* @param request
* The request to add.
* @param future
* The future object for the response.
* @throws JGBXException
* The request could not been queued.
*/
<T extends Response> void addRequest(final Request request, final JGBXFuture<T> future) throws JGBXException {
Integer handle;
synchronized (this.messageHandleLock) {
handle = this.nextMessageHandle;
this.nextMessageHandle++;
if (this.nextMessageHandle >= 0) {
this.nextMessageHandle = Integer.MIN_VALUE;
}
}
try {
this.responseQueue.put(handle, future);
this.requestQueue.put(new RequestContainer(handle, request));
if (request instanceof EnableCallbacksRequest) {
this.callbacksEnabled = request.<Boolean> getParameter(0);
}
}
catch (final InterruptedException e) {
this.responseQueue.remove(this.nextMessageHandle);
throw new JGBXException(e);
}
}
}