/*
* socket.io-java-client IOConnection.java
*
* Copyright (c) 2012, Enno Boland
* socket.io-java-client is a implementation of the socket.io protocol in Java.
*
* See LICENSE file for more information
*/
package io.socket;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.util.Map.Entry;
import java.util.*;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.logging.Logger;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/**
* The Class IOConnection.
*/
class IOConnection implements IOCallback {
/** Debug logger */
static final Logger logger = Logger.getLogger("io.socket");
public static final String FRAME_DELIMITER = "\ufffd";
/** The Constant STATE_INIT. */
private static final int STATE_INIT = 0;
/** The Constant STATE_HANDSHAKE. */
private static final int STATE_HANDSHAKE = 1;
/** The Constant STATE_CONNECTING. */
private static final int STATE_CONNECTING = 2;
/** The Constant STATE_READY. */
private static final int STATE_READY = 3;
/** The Constant STATE_INTERRUPTED. */
private static final int STATE_INTERRUPTED = 4;
/** The Constant STATE_INVALID. */
private static final int STATE_INVALID = 6;
/** The state. */
private int state = STATE_INIT;
/** Socket.io path. */
public static final String SOCKET_IO_1 = "/socket.io/1/";
/** The SSL socket factory for HTTPS connections */
private static SSLContext sslContext = null;
/** All available connections. */
private static HashMap<String, List<IOConnection>> connections = new HashMap<String, List<IOConnection>>();
/** The url for this connection. */
private URL url;
/** The transport for this connection. */
private IOTransport transport;
/** The connection timeout. */
private int connectTimeout = 10000;
/** The session id of this connection. */
private String sessionId;
/** The heartbeat timeout. Set by the server */
private long heartbeatTimeout;
/** The closing timeout. Set By the server */
private long closingTimeout;
/** The protocols supported by the server. */
private List<String> protocols;
/** The output buffer used to cache messages while (re-)connecting. */
private ConcurrentLinkedQueue<String> outputBuffer = new ConcurrentLinkedQueue<String>();
/** The sockets of this connection. */
private HashMap<String, SocketIO> sockets = new HashMap<String, SocketIO>();
/** Custom Request headers used while handshaking */
private Properties headers;
/**
* The first socket to be connected. the socket.io server does not send a
* connected response to this one.
*/
private SocketIO firstSocket = null;
/** The reconnect timer. IOConnect waits a second before trying to reconnect */
final private Timer backgroundTimer = new Timer("backgroundTimer");
/** A String representation of {@link #url}. */
private String urlStr;
/**
* The last occurred exception, which will be given to the user if
* IOConnection gives up.
*/
private Exception lastException;
/** The next ID to use. */
private int nextId = 1;
/** Acknowledges. */
HashMap<Integer, IOAcknowledge> acknowledge = new HashMap<Integer, IOAcknowledge>();
/** true if there's already a keepalive in {@link #outputBuffer}. */
private boolean keepAliveInQueue;
/**
* The heartbeat timeout task. Only null before connection has been
* initialised.
*/
private HearbeatTimeoutTask heartbeatTimeoutTask;
/**
* The Class HearbeatTimeoutTask. Handles dropping this IOConnection if no
* heartbeat is received within life time.
*/
private class HearbeatTimeoutTask extends TimerTask {
/*
* (non-Javadoc)
*
* @see java.util.TimerTask#run()
*/
@Override
public void run() {
error(new SocketIOException(
"Timeout Error. No heartbeat from server within life time of the socket. closing.",
lastException));
}
}
/** The reconnect task. Null if no reconnection is in progress. */
private ReconnectTask reconnectTask = null;
/**
* The Class ReconnectTask. Handles reconnect attempts
*/
private class ReconnectTask extends TimerTask {
/*
* (non-Javadoc)
*
* @see java.util.TimerTask#run()
*/
@Override
public void run() {
connectTransport();
if (!keepAliveInQueue) {
sendPlain("2::");
keepAliveInQueue = true;
}
}
}
/**
* The Class ConnectThread. Handles connecting to the server with an
* {@link IOTransport}
*/
private class ConnectThread extends Thread {
/**
* Instantiates a new thread for handshaking/connecting.
*/
public ConnectThread() {
super("ConnectThread");
}
/**
* Tries handshaking if necessary and connects with corresponding
* transport afterwards.
*/
@Override
public void run() {
if (IOConnection.this.getState() == STATE_INIT)
handshake();
connectTransport();
}
};
/**
* Set the socket factory used for SSL connections.
*
* @param sslContext
*/
public static void setSslContext(SSLContext sslContext) {
IOConnection.sslContext = sslContext;
}
/**
* Get the socket factory used for SSL connections.
*
* @return socketFactory
*/
public static SSLContext getSslContext() {
return sslContext;
}
/**
* Creates a new connection or returns the corresponding one.
*
* @param origin
* the origin
* @param socket
* the socket
* @return a IOConnection object
*/
static public IOConnection register(String origin, SocketIO socket) {
List<IOConnection> list = connections.get(origin);
if (list == null) {
list = new LinkedList<IOConnection>();
connections.put(origin, list);
} else {
synchronized (list) {
for (IOConnection connection : list) {
if (connection.register(socket))
return connection;
}
}
}
IOConnection connection = new IOConnection(origin, socket);
list.add(connection);
return connection;
}
/**
* Connects a socket to the IOConnection.
*
* @param socket
* the socket to be connected
* @return true, if successfully registered on this transport, otherwise
* false.
*/
public synchronized boolean register(SocketIO socket) {
String namespace = socket.getNamespace();
if (sockets.containsKey(namespace))
return false;
sockets.put(namespace, socket);
socket.setHeaders(headers);
IOMessage connect = new IOMessage(IOMessage.TYPE_CONNECT,
socket.getNamespace(), "");
sendPlain(connect.toString());
return true;
}
/**
* Disconnect a socket from the IOConnection. Shuts down this IOConnection
* if no further connections are available for this IOConnection.
*
* @param socket
* the socket to be shut down
*/
public synchronized void unregister(SocketIO socket) {
sendPlain("0::" + socket.getNamespace());
sockets.remove(socket.getNamespace());
socket.getCallback().onDisconnect();
if (sockets.size() == 0) {
cleanup();
}
}
/**
* Handshake.
*
*/
private void handshake() {
URL url;
String response;
URLConnection connection;
try {
setState(STATE_HANDSHAKE);
url = new URL(IOConnection.this.url.toString() + SOCKET_IO_1);
connection = url.openConnection();
if (connection instanceof HttpsURLConnection) {
((HttpsURLConnection) connection)
.setSSLSocketFactory(sslContext.getSocketFactory());
}
connection.setConnectTimeout(connectTimeout);
connection.setReadTimeout(connectTimeout);
/* Setting the request headers */
for (Entry<Object, Object> entry : headers.entrySet()) {
connection.setRequestProperty((String) entry.getKey(),
(String) entry.getValue());
}
InputStream stream = connection.getInputStream();
Scanner in = new Scanner(stream);
response = in.nextLine();
String[] data = response.split(":");
sessionId = data[0];
heartbeatTimeout = Long.parseLong(data[1]) * 1000;
closingTimeout = Long.parseLong(data[2]) * 1000;
protocols = Arrays.asList(data[3].split(","));
} catch (Exception e) {
error(new SocketIOException("Error while handshaking", e));
}
}
/**
* Connect transport.
*/
private synchronized void connectTransport() {
if (getState() == STATE_INVALID)
return;
setState(STATE_CONNECTING);
if (protocols.contains(WebsocketTransport.TRANSPORT_NAME))
transport = WebsocketTransport.create(url, this);
else if (protocols.contains(XhrTransport.TRANSPORT_NAME))
transport = XhrTransport.create(url, this);
else {
error(new SocketIOException(
"Server supports no available transports. You should reconfigure the server to support a available transport"));
return;
}
transport.connect();
}
/**
* Creates a new {@link IOAcknowledge} instance which sends its arguments
* back to the server.
*
* @param message
* the message
* @return an {@link IOAcknowledge} instance, may be <code>null</code> if
* server doesn't request one.
*/
private IOAcknowledge remoteAcknowledge(IOMessage message) {
String _id = message.getId();
if (_id.equals(""))
return null;
else if (_id.endsWith("+") == false)
_id = _id + "+";
final String id = _id;
final String endPoint = message.getEndpoint();
return new IOAcknowledge() {
@Override
public void ack(Object... args) {
JSONArray array = new JSONArray();
for (Object o : args) {
try {
array.put(o == null ? JSONObject.NULL : o);
} catch (Exception e) {
error(new SocketIOException(
"You can only put values in IOAcknowledge.ack() which can be handled by JSONArray.put()",
e));
}
}
IOMessage ackMsg = new IOMessage(IOMessage.TYPE_ACK, endPoint,
id + array.toString());
sendPlain(ackMsg.toString());
}
};
}
/**
* adds an {@link IOAcknowledge} to an {@link IOMessage}.
*
* @param message
* the {@link IOMessage}
* @param ack
* the {@link IOAcknowledge}
*/
private void synthesizeAck(IOMessage message, IOAcknowledge ack) {
if (ack != null) {
int id = nextId++;
acknowledge.put(id, ack);
message.setId(id + "+");
}
}
/**
* Instantiates a new IOConnection.
*
* @param url
* the URL
* @param socket
* the socket
*/
private IOConnection(String url, SocketIO socket) {
try {
this.url = new URL(url);
this.urlStr = url;
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
firstSocket = socket;
headers = socket.getHeaders();
sockets.put(socket.getNamespace(), socket);
new ConnectThread().start();
}
/**
* Cleanup. IOConnection is not usable after this calling this.
*/
private synchronized void cleanup() {
setState(STATE_INVALID);
if (transport != null)
transport.disconnect();
sockets.clear();
synchronized (connections) {
List<IOConnection> con = connections.get(urlStr);
if (con != null && con.size() > 1)
con.remove(this);
else
connections.remove(urlStr);
}
logger.info("Cleanup");
backgroundTimer.cancel();
}
/**
* Populates an error to the connected {@link IOCallback}s and shuts down.
*
* @param e
* an exception
*/
private void error(SocketIOException e) {
for (SocketIO socket : sockets.values()) {
socket.getCallback().onError(e);
}
cleanup();
}
/**
* Sends a plain message to the {@link IOTransport}.
*
* @param text
* the Text to be send.
*/
private synchronized void sendPlain(String text) {
if (getState() == STATE_READY)
try {
logger.info("> " + text);
transport.send(text);
} catch (Exception e) {
logger.info("IOEx: saving");
outputBuffer.add(text);
}
else {
outputBuffer.add(text);
}
}
/**
* Invalidates an {@link IOTransport}, used for forced reconnecting.
*/
private void invalidateTransport() {
if (transport != null)
transport.invalidate();
transport = null;
}
/**
* Reset timeout.
*/
private synchronized void resetTimeout() {
if (heartbeatTimeoutTask != null) {
heartbeatTimeoutTask.cancel();
}
if(getState() != STATE_INVALID) {
heartbeatTimeoutTask = new HearbeatTimeoutTask();
backgroundTimer.schedule(heartbeatTimeoutTask, closingTimeout
+ heartbeatTimeout);
}
}
/**
* finds the corresponding callback object to an incoming message. Returns a
* dummy callback if no corresponding callback can be found
*
* @param message
* the message
* @return the iO callback
* @throws SocketIOException
*/
private IOCallback findCallback(IOMessage message) throws SocketIOException {
if ("".equals(message.getEndpoint()))
return this;
SocketIO socket = sockets.get(message.getEndpoint());
if (socket == null) {
throw new SocketIOException("Cannot find socket for '"
+ message.getEndpoint() + "'");
}
return socket.getCallback();
}
/**
* Transport connected.
*
* {@link IOTransport} calls this when a connection is established.
*/
public synchronized void transportConnected() {
setState(STATE_READY);
if (reconnectTask != null) {
reconnectTask.cancel();
reconnectTask = null;
}
resetTimeout();
if (transport.canSendBulk()) {
ConcurrentLinkedQueue<String> outputBuffer = this.outputBuffer;
this.outputBuffer = new ConcurrentLinkedQueue<String>();
try {
// DEBUG
String[] texts = outputBuffer.toArray(new String[outputBuffer
.size()]);
logger.info("Bulk start:");
for (String text : texts) {
logger.info("> " + text);
}
logger.info("Bulk end");
// DEBUG END
transport.sendBulk(texts);
} catch (IOException e) {
this.outputBuffer = outputBuffer;
}
} else {
String text;
while ((text = outputBuffer.poll()) != null)
sendPlain(text);
}
this.keepAliveInQueue = false;
}
/**
* Transport disconnected.
*
* {@link IOTransport} calls this when a connection has been shut down.
*/
public void transportDisconnected() {
this.lastException = null;
setState(STATE_INTERRUPTED);
reconnect();
}
/**
* Transport error.
*
* @param error
* the error {@link IOTransport} calls this, when an exception
* has occurred and the transport is not usable anymore.
*/
public void transportError(Exception error) {
this.lastException = error;
setState(STATE_INTERRUPTED);
reconnect();
}
/**
* {@link IOTransport} should call this function if it does not support
* framing. If it does, transportMessage should be used
*
* @param text
* the text
*/
public void transportData(String text) {
if (!text.startsWith(FRAME_DELIMITER)) {
transportMessage(text);
return;
}
Iterator<String> fragments = Arrays.asList(text.split(FRAME_DELIMITER))
.listIterator(1);
while (fragments.hasNext()) {
int length = Integer.parseInt(fragments.next());
String string = (String) fragments.next();
// Potential BUG: it is not defined if length is in bytes or
// characters. Assuming characters.
if (length != string.length()) {
error(new SocketIOException("Garbage from server: " + text));
return;
}
transportMessage(string);
}
}
/**
* Transport message. {@link IOTransport} calls this, when a message has
* been received.
*
* @param text
* the text
*/
public void transportMessage(String text) {
logger.info("< " + text);
IOMessage message;
try {
message = new IOMessage(text);
} catch (Exception e) {
error(new SocketIOException("Garbage from server: " + text, e));
return;
}
resetTimeout();
switch (message.getType()) {
case IOMessage.TYPE_DISCONNECT:
try {
findCallback(message).onDisconnect();
} catch (Exception e) {
error(new SocketIOException(
"Exception was thrown in onDisconnect()", e));
}
break;
case IOMessage.TYPE_CONNECT:
try {
if (firstSocket != null && "".equals(message.getEndpoint())) {
if (firstSocket.getNamespace().equals("")) {
firstSocket.getCallback().onConnect();
} else {
IOMessage connect = new IOMessage(
IOMessage.TYPE_CONNECT,
firstSocket.getNamespace(), "");
sendPlain(connect.toString());
}
} else {
findCallback(message).onConnect();
}
firstSocket = null;
} catch (Exception e) {
error(new SocketIOException(
"Exception was thrown in onConnect()", e));
}
break;
case IOMessage.TYPE_HEARTBEAT:
sendPlain("2::");
break;
case IOMessage.TYPE_MESSAGE:
try {
findCallback(message).onMessage(message.getData(),
remoteAcknowledge(message));
} catch (Exception e) {
error(new SocketIOException(
"Exception was thrown in onMessage(String).\n"
+ "Message was: " + message.toString(), e));
}
break;
case IOMessage.TYPE_JSON_MESSAGE:
try {
JSONObject obj = null;
String data = message.getData();
if (data.trim().equals("null") == false)
obj = new JSONObject(data);
try {
findCallback(message).onMessage(obj,
remoteAcknowledge(message));
} catch (Exception e) {
error(new SocketIOException(
"Exception was thrown in onMessage(JSONObject).\n"
+ "Message was: " + message.toString(), e));
}
} catch (JSONException e) {
logger.warning("Malformated JSON received");
}
break;
case IOMessage.TYPE_EVENT:
try {
JSONObject event = new JSONObject(message.getData());
Object[] argsArray;
if (event.has("args")) {
JSONArray args = event.getJSONArray("args");
argsArray = new Object[args.length()];
for (int i = 0; i < args.length(); i++) {
if (args.isNull(i) == false)
argsArray[i] = args.get(i);
}
} else
argsArray = new Object[0];
String eventName = event.getString("name");
try {
findCallback(message).on(eventName,
remoteAcknowledge(message), argsArray);
} catch (Exception e) {
error(new SocketIOException(
"Exception was thrown in on(String, JSONObject[]).\n"
+ "Message was: " + message.toString(), e));
}
} catch (JSONException e) {
logger.warning("Malformated JSON received");
}
break;
case IOMessage.TYPE_ACK:
String[] data = message.getData().split("\\+", 2);
if (data.length == 2) {
try {
int id = Integer.parseInt(data[0]);
IOAcknowledge ack = acknowledge.get(id);
if (ack == null)
logger.warning("Received unknown ack packet");
else {
JSONArray array = new JSONArray(data[1]);
Object[] args = new Object[array.length()];
for (int i = 0; i < args.length; i++) {
args[i] = array.get(i);
}
ack.ack(args);
}
} catch (NumberFormatException e) {
logger.warning("Received malformated Acknowledge! This is potentially filling up the acknowledges!");
} catch (JSONException e) {
logger.warning("Received malformated Acknowledge data!");
}
} else if (data.length == 1) {
sendPlain("6:::" + data[0]);
}
break;
case IOMessage.TYPE_ERROR:
try {
findCallback(message).onError(
new SocketIOException(message.getData()));
} catch (SocketIOException e) {
error(e);
}
if (message.getData().endsWith("+0")) {
// We are advised to disconnect
cleanup();
}
break;
case IOMessage.TYPE_NOOP:
break;
default:
logger.warning("Unkown type received" + message.getType());
break;
}
}
/**
* forces a reconnect. This had become useful on some android devices which
* do not shut down TCP-connections when switching from HSDPA to Wifi
*/
public synchronized void reconnect() {
if (getState() != STATE_INVALID) {
invalidateTransport();
setState(STATE_INTERRUPTED);
if (reconnectTask != null) {
reconnectTask.cancel();
}
reconnectTask = new ReconnectTask();
backgroundTimer.schedule(reconnectTask, 1000);
}
}
/**
* Returns the session id. This should be called from a {@link IOTransport}
*
* @return the session id to connect to the right Session.
*/
public String getSessionId() {
return sessionId;
}
/**
* sends a String message from {@link SocketIO} to the {@link IOTransport}.
*
* @param socket
* the socket
* @param ack
* acknowledge package which can be called from the server
* @param text
* the text
*/
public void send(SocketIO socket, IOAcknowledge ack, String text) {
IOMessage message = new IOMessage(IOMessage.TYPE_MESSAGE,
socket.getNamespace(), text);
synthesizeAck(message, ack);
sendPlain(message.toString());
}
/**
* sends a JSON message from {@link SocketIO} to the {@link IOTransport}.
*
* @param socket
* the socket
* @param ack
* acknowledge package which can be called from the server
* @param json
* the json
*/
public void send(SocketIO socket, IOAcknowledge ack, JSONObject json) {
IOMessage message = new IOMessage(IOMessage.TYPE_JSON_MESSAGE,
socket.getNamespace(), json.toString());
synthesizeAck(message, ack);
sendPlain(message.toString());
}
/**
* emits an event from {@link SocketIO} to the {@link IOTransport}.
*
* @param socket
* the socket
* @param event
* the event
* @param ack
* acknowledge package which can be called from the server
* @param args
* the arguments to be send
*/
public void emit(SocketIO socket, String event, IOAcknowledge ack,
Object... args) {
try {
JSONObject json = new JSONObject().put("name", event).put("args",
new JSONArray(Arrays.asList(args)));
IOMessage message = new IOMessage(IOMessage.TYPE_EVENT,
socket.getNamespace(), json.toString());
synthesizeAck(message, ack);
sendPlain(message.toString());
} catch (JSONException e) {
error(new SocketIOException(
"Error while emitting an event. Make sure you only try to send arguments, which can be serialized into JSON."));
}
}
/**
* Checks if IOConnection is currently connected.
*
* @return true, if is connected
*/
public boolean isConnected() {
return getState() == STATE_READY;
}
/**
* Gets the current state of this IOConnection.
*
* @return current state
*/
private synchronized int getState() {
return state;
}
/**
* Sets the current state of this IOConnection.
*
* @param state
* the new state
*/
private synchronized void setState(int state) {
this.state = state;
}
/**
* gets the currently used transport.
*
* @return currently used transport
*/
public IOTransport getTransport() {
return transport;
}
@Override
public void onDisconnect() {
SocketIO socket = sockets.get("");
if (socket != null)
socket.getCallback().onDisconnect();
}
@Override
public void onConnect() {
SocketIO socket = sockets.get("");
if (socket != null)
socket.getCallback().onConnect();
}
@Override
public void onMessage(String data, IOAcknowledge ack) {
for (SocketIO socket : sockets.values())
socket.getCallback().onMessage(data, ack);
}
@Override
public void onMessage(JSONObject json, IOAcknowledge ack) {
for (SocketIO socket : sockets.values())
socket.getCallback().onMessage(json, ack);
}
@Override
public void on(String event, IOAcknowledge ack, Object... args) {
for (SocketIO socket : sockets.values())
socket.getCallback().on(event, ack, args);
}
@Override
public void onError(SocketIOException socketIOException) {
for (SocketIO socket : sockets.values())
socket.getCallback().onError(socketIOException);
}
}