diff --git a/.gitignore b/.gitignore index 81d81b4..a361a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ -gen/ +bin +gen +.classpath +.project local.properties +/.settings +project.properties diff --git a/AndroidManifest.xml b/AndroidManifest.xml index eb6f10c..80e8295 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -4,4 +4,8 @@ package="com.codebutler.android_websockets" android:versionCode="1" android:versionName="0.01"> + + diff --git a/README.md b/README.md index 17a934d..26aa1f7 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,36 @@ -# WebSocket client for Android +# THIS LIBRARY IS DEPRECATED IN FAVOR OF: -A very simple bare-minimum WebSocket client for Android. +[AndroidAsync](https://github.com/koush/AndroidAsync) + + + + + + + + +# WebSocket and Socket.IO client for Android ## Credits The hybi parser is based on code from the [faye project](https://github.com/faye/faye-websocket-node). Faye is Copyright (c) 2009-2012 James Coglan. Many thanks for the great open-source library! -Ported from JavaScript to Java by [Eric Butler](https://twitter.com/codebutler) . +The hybi parser was ported from JavaScript to Java by [Eric Butler](https://twitter.com/codebutler) . -## Usage +The WebSocket client was written by [Eric Butler](https://twitter.com/codebutler) . -Here's the entire API: +The Socket.IO client was written by [Koushik Dutta](https://twitter.com/koush). + +The Socket.IO client component was ported from Koushik Dutta's AndroidAsync(https://github.com/koush/AndroidAsync) by [Vinay S Shenoy](https://twitter.com/vinaysshenoy) + +## WebSocket Usage ```java List extraHeaders = Arrays.asList( - new BasicNameValuePair("Cookie", "session=abcd"); + new BasicNameValuePair("Cookie", "session=abcd") ); -WebSocketClient client = new WebSocketClient(URI.create("wss://irccloud.com"), new WebSocketClient.Handler() { +WebSocketClient client = new WebSocketClient(URI.create("wss://irccloud.com"), new WebSocketClient.Listener() { @Override public void onConnect() { Log.d(TAG, "Connected!"); @@ -30,7 +43,7 @@ WebSocketClient client = new WebSocketClient(URI.create("wss://irccloud.com"), n @Override public void onMessage(byte[] data) { - Log.d(TAG, String.format("Got binary message! %s", toHexString(data)); + Log.d(TAG, String.format("Got binary message! %s", toHexString(data))); } @Override @@ -42,6 +55,7 @@ WebSocketClient client = new WebSocketClient(URI.create("wss://irccloud.com"), n public void onError(Exception error) { Log.e(TAG, "Error!", error); } + }, extraHeaders); client.connect(); @@ -52,6 +66,94 @@ client.send(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }); client.disconnect(); ``` +## Socket.IO Usage + +```java +SocketIOClient.connect("http://localhost:80", new ConnectCallback() { + + @Override + public void onConnectCompleted(Exception ex, SocketIOClient client) { + + if (ex != null) { + return; + } + + //Save the returned SocketIOClient instance into a variable so you can disconnect it later + client.setDisconnectCallback(MainActivity.this); + client.setErrorCallback(MainActivity.this); + client.setJSONCallback(MainActivity.this); + client.setStringCallback(MainActivity.this); + + //You need to explicitly specify which events you are interested in receiving + client.addListener("news", MainActivity.this); + + client.of("/chat", new ConnectCallback() { + + @Override + public void onConnectCompleted(Exception ex, SocketIOClient client) { + + if (ex != null) { + ex.printStackTrace(); + return; + } + + //This client instance will be using the same websocket as the original client, + //but will point to the indicated endpoint + client.setDisconnectCallback(MainActivity.this); + client.setErrorCallback(MainActivity.this); + client.setJSONCallback(MainActivity.this); + client.setStringCallback(MainActivity.this); + client.addListener("a message", MainActivity.this); + + } + }); + + } +}, new Handler()); + + +@Override +public void onEvent(String event, JSONArray argument, Acknowledge acknowledge) { + try { + Log.d("MainActivity", "Event:" + event + "Arguments:" + + argument.toString(2)); + } catch (JSONException e) { + e.printStackTrace(); + } + +} + +@Override +public void onString(String string, Acknowledge acknowledge) { + Log.d("MainActivity", string); + +} + +@Override +public void onJSON(JSONObject json, Acknowledge acknowledge) { + try { + Log.d("MainActivity", "json:" + json.toString(2)); + } catch (JSONException e) { + e.printStackTrace(); + } + +} + +@Override +public void onError(String error) { + Log.d("MainActivity", error); + +} + +@Override +public void onDisconnect(Exception e) { + Log.d(mComponentTag, "Disconnected:" + e.getMessage()); + +} + +``` + + ## TODO @@ -64,6 +166,7 @@ client.disconnect(); Copyright (c) 2009-2012 James Coglan Copyright (c) 2012 Eric Butler + Copyright (c) 2012 Koushik Dutta Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in diff --git a/build.xml b/build.xml index 7db9217..876bc4b 100644 --- a/build.xml +++ b/build.xml @@ -1,5 +1,5 @@ - + + + + + + + diff --git a/proguard-project.txt b/proguard-project.txt new file mode 100644 index 0000000..f2fe155 --- /dev/null +++ b/proguard-project.txt @@ -0,0 +1,20 @@ +# To enable ProGuard in your project, edit project.properties +# to define the proguard.config property as described in that file. +# +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in ${sdk.dir}/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the ProGuard +# include property in project.properties. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/project.properties b/project.properties index 1d35d2d..cd0ca12 100644 --- a/project.properties +++ b/project.properties @@ -12,4 +12,4 @@ android.library=true # Project target. -target=android-14 +target=android-8 diff --git a/src/com/codebutler/android_websockets/HybiParser.java b/src/com/codebutler/android_websockets/HybiParser.java index e7a455b..e13eb8d 100644 --- a/src/com/codebutler/android_websockets/HybiParser.java +++ b/src/com/codebutler/android_websockets/HybiParser.java @@ -149,7 +149,7 @@ private void parseOpcode(byte data) throws ProtocolError { throw new ProtocolError("Bad opcode"); } - if (FRAGMENTED_OPCODES.contains(mOpcode) && !mFinal) { + if (!FRAGMENTED_OPCODES.contains(mOpcode) && !mFinal) { throw new ProtocolError("Expected non-final packet"); } @@ -332,9 +332,42 @@ private int getInteger(byte[] bytes) throws ProtocolError { } return (int) i; } + + /** + * Copied from AOSP Arrays.java. + */ + /** + * Copies elements from {@code original} into a new array, from indexes start (inclusive) to + * end (exclusive). The original order of elements is preserved. + * If {@code end} is greater than {@code original.length}, the result is padded + * with the value {@code (byte) 0}. + * + * @param original the original array + * @param start the start index, inclusive + * @param end the end index, exclusive + * @return the new array + * @throws ArrayIndexOutOfBoundsException if {@code start < 0 || start > original.length} + * @throws IllegalArgumentException if {@code start > end} + * @throws NullPointerException if {@code original == null} + * @since 1.6 + */ + private static byte[] copyOfRange(byte[] original, int start, int end) { + if (start > end) { + throw new IllegalArgumentException(); + } + int originalLength = original.length; + if (start < 0 || start > originalLength) { + throw new ArrayIndexOutOfBoundsException(); + } + int resultLength = end - start; + int copyLength = Math.min(resultLength, originalLength - start); + byte[] result = new byte[resultLength]; + System.arraycopy(original, start, result, 0, copyLength); + return result; + } private byte[] slice(byte[] array, int start) { - return Arrays.copyOfRange(array, start, array.length); + return copyOfRange(array, start, array.length); } public static class ProtocolError extends IOException { diff --git a/src/com/codebutler/android_websockets/WebSocketClient.java b/src/com/codebutler/android_websockets/WebSocketClient.java index 7e3343c..84b46b2 100644 --- a/src/com/codebutler/android_websockets/WebSocketClient.java +++ b/src/com/codebutler/android_websockets/WebSocketClient.java @@ -22,6 +22,7 @@ import java.net.Socket; import java.net.URI; import java.security.KeyManagementException; +import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.List; @@ -36,6 +37,7 @@ public class WebSocketClient { private Handler mHandler; private List mExtraHeaders; private HybiParser mParser; + private boolean mConnected; private final Object mSendLock = new Object(); @@ -47,8 +49,9 @@ public static void setTrustManagers(TrustManager[] tm) { public WebSocketClient(URI uri, Listener listener, List extraHeaders) { mURI = uri; - mListener = listener; + mListener = listener; mExtraHeaders = extraHeaders; + mConnected = false; mParser = new HybiParser(this); mHandlerThread = new HandlerThread("websocket-thread"); @@ -69,7 +72,7 @@ public void connect() { @Override public void run() { try { - int port = (mURI.getPort() != -1) ? mURI.getPort() : (mURI.getScheme().equals("wss") ? 443 : 80); + int port = (mURI.getPort() != -1) ? mURI.getPort() : ((mURI.getScheme().equals("wss") || mURI.getScheme().equals("https")) ? 443 : 80); String path = TextUtils.isEmpty(mURI.getPath()) ? "/" : mURI.getPath(); if (!TextUtils.isEmpty(mURI.getQuery())) { @@ -79,16 +82,17 @@ public void run() { String originScheme = mURI.getScheme().equals("wss") ? "https" : "http"; URI origin = new URI(originScheme, "//" + mURI.getHost(), null); - SocketFactory factory = mURI.getScheme().equals("wss") ? getSSLSocketFactory() : SocketFactory.getDefault(); + SocketFactory factory = (mURI.getScheme().equals("wss") || mURI.getScheme().equals("https")) ? getSSLSocketFactory() : SocketFactory.getDefault(); mSocket = factory.createSocket(mURI.getHost(), port); PrintWriter out = new PrintWriter(mSocket.getOutputStream()); + String secretKey = createSecret(); out.print("GET " + path + " HTTP/1.1\r\n"); out.print("Upgrade: websocket\r\n"); out.print("Connection: Upgrade\r\n"); out.print("Host: " + mURI.getHost() + "\r\n"); out.print("Origin: " + origin.toString() + "\r\n"); - out.print("Sec-WebSocket-Key: " + createSecret() + "\r\n"); + out.print("Sec-WebSocket-Key: " + secretKey + "\r\n"); out.print("Sec-WebSocket-Version: 13\r\n"); if (mExtraHeaders != null) { for (NameValuePair pair : mExtraHeaders) { @@ -113,23 +117,32 @@ public void run() { while (!TextUtils.isEmpty(line = readLine(stream))) { Header header = parseHeader(line); if (header.getName().equals("Sec-WebSocket-Accept")) { - // FIXME: Verify the response... + String expected = expectedKey(secretKey); + if (expected == null) { + throw new Exception("SHA-1 algorithm not found"); + } else if (!expected.equals(header.getValue())) { + throw new Exception("Invalid Sec-WebSocket-Accept, expected: " + expected + ", got: " + header.getValue()); + } } } mListener.onConnect(); + mConnected = true; + // Now decode websocket frames. mParser.start(stream); } catch (EOFException ex) { Log.d(TAG, "WebSocket EOF!", ex); mListener.onDisconnect(0, "EOF"); + mConnected = false; } catch (SSLException ex) { // Connection reset by peer Log.d(TAG, "Websocket SSL error!", ex); mListener.onDisconnect(0, "SSL"); + mConnected = false; } catch (Exception ex) { mListener.onError(ex); @@ -144,13 +157,16 @@ public void disconnect() { mHandler.post(new Runnable() { @Override public void run() { - try { - mSocket.close(); + if (mSocket != null) { + try { + mSocket.close(); + } catch (IOException ex) { + Log.d(TAG, "Error while disconnecting", ex); + mListener.onError(ex); + } mSocket = null; - } catch (IOException ex) { - Log.d(TAG, "Error while disconnecting", ex); - mListener.onError(ex); } + mConnected = false; } }); } @@ -164,6 +180,10 @@ public void send(byte[] data) { sendFrame(mParser.frame(data)); } + public boolean isConnected() { + return mConnected; + } + private StatusLine parseStatusLine(String line) { if (TextUtils.isEmpty(line)) { return null; @@ -195,6 +215,19 @@ private String readLine(HybiParser.HappyDataInputStream reader) throws IOExcepti return string.toString(); } + private String expectedKey(String secret) { + //concatenate, SHA1-hash, base64-encode + try { + final String GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + final String secretGUID = secret + GUID; + MessageDigest md = MessageDigest.getInstance("SHA-1"); + byte[] digest = md.digest(secretGUID.getBytes()); + return Base64.encodeToString(digest, Base64.DEFAULT).trim(); + } catch (NoSuchAlgorithmException e) { + return null; + } + } + private String createSecret() { byte[] nonce = new byte[16]; for (int i = 0; i < 16; i++) { @@ -233,4 +266,4 @@ private SSLSocketFactory getSSLSocketFactory() throws NoSuchAlgorithmException, context.init(null, sTrustManagers, null); return context.getSocketFactory(); } -} +} \ No newline at end of file diff --git a/src/com/koushikdutta/async/http/socketio/Acknowledge.java b/src/com/koushikdutta/async/http/socketio/Acknowledge.java new file mode 100644 index 0000000..8bf1c64 --- /dev/null +++ b/src/com/koushikdutta/async/http/socketio/Acknowledge.java @@ -0,0 +1,7 @@ +package com.koushikdutta.async.http.socketio; + +import org.json.JSONArray; + +public interface Acknowledge { + void acknowledge(JSONArray arguments); +} diff --git a/src/com/koushikdutta/async/http/socketio/ConnectCallback.java b/src/com/koushikdutta/async/http/socketio/ConnectCallback.java new file mode 100644 index 0000000..7ff69d7 --- /dev/null +++ b/src/com/koushikdutta/async/http/socketio/ConnectCallback.java @@ -0,0 +1,5 @@ +package com.koushikdutta.async.http.socketio; + +public interface ConnectCallback { + public void onConnectCompleted(Exception ex, SocketIOClient client); +} \ No newline at end of file diff --git a/src/com/koushikdutta/async/http/socketio/DisconnectCallback.java b/src/com/koushikdutta/async/http/socketio/DisconnectCallback.java new file mode 100644 index 0000000..97ec4b6 --- /dev/null +++ b/src/com/koushikdutta/async/http/socketio/DisconnectCallback.java @@ -0,0 +1,8 @@ +package com.koushikdutta.async.http.socketio; + +/** + * Created by koush on 7/2/13. + */ +public interface DisconnectCallback { + void onDisconnect(Exception e); +} diff --git a/src/com/koushikdutta/async/http/socketio/ErrorCallback.java b/src/com/koushikdutta/async/http/socketio/ErrorCallback.java new file mode 100644 index 0000000..6411667 --- /dev/null +++ b/src/com/koushikdutta/async/http/socketio/ErrorCallback.java @@ -0,0 +1,8 @@ +package com.koushikdutta.async.http.socketio; + +/** + * Created by koush on 7/2/13. + */ +public interface ErrorCallback { + void onError(String error); +} diff --git a/src/com/koushikdutta/async/http/socketio/EventCallback.java b/src/com/koushikdutta/async/http/socketio/EventCallback.java new file mode 100644 index 0000000..d416b2d --- /dev/null +++ b/src/com/koushikdutta/async/http/socketio/EventCallback.java @@ -0,0 +1,7 @@ +package com.koushikdutta.async.http.socketio; + +import org.json.JSONArray; + +public interface EventCallback { + public void onEvent(String event, JSONArray argument, Acknowledge acknowledge); +} \ No newline at end of file diff --git a/src/com/koushikdutta/async/http/socketio/EventEmitter.java b/src/com/koushikdutta/async/http/socketio/EventEmitter.java new file mode 100644 index 0000000..f6dbb7a --- /dev/null +++ b/src/com/koushikdutta/async/http/socketio/EventEmitter.java @@ -0,0 +1,55 @@ +package com.koushikdutta.async.http.socketio; + +import com.koushikdutta.async.util.HashList; + +import org.json.JSONArray; + +import java.util.Iterator; +import java.util.List; + +/** + * Created by koush on 7/1/13. + */ +public class EventEmitter { + interface OnceCallback extends EventCallback { + } + + HashList callbacks = new HashList(); + + void onEvent(String event, JSONArray arguments, Acknowledge acknowledge) { + List list = callbacks.get(event); + if (list == null) + return; + Iterator iter = list.iterator(); + while (iter.hasNext()) { + EventCallback cb = iter.next(); + cb.onEvent(event, arguments, acknowledge); + if (cb instanceof OnceCallback) + iter.remove(); + } + } + + public void addListener(String event, EventCallback callback) { + on(event, callback); + } + + public void once(final String event, final EventCallback callback) { + on(event, new OnceCallback() { + @Override + public void onEvent(String event, JSONArray arguments, Acknowledge acknowledge) { + callback.onEvent(event, arguments, acknowledge); + } + }); + } + + public void on(String event, EventCallback callback) { + callbacks.add(event, callback); + } + + public void removeListener(String event, EventCallback callback) { + List list = callbacks.get(event); + if (list == null) + return; + list.remove(callback); + } +} diff --git a/src/com/koushikdutta/async/http/socketio/JSONCallback.java b/src/com/koushikdutta/async/http/socketio/JSONCallback.java new file mode 100644 index 0000000..52d8aff --- /dev/null +++ b/src/com/koushikdutta/async/http/socketio/JSONCallback.java @@ -0,0 +1,8 @@ +package com.koushikdutta.async.http.socketio; + +import org.json.JSONObject; + +public interface JSONCallback { + public void onJSON(JSONObject json, Acknowledge acknowledge); +} + diff --git a/src/com/koushikdutta/async/http/socketio/ReconnectCallback.java b/src/com/koushikdutta/async/http/socketio/ReconnectCallback.java new file mode 100644 index 0000000..bd427f4 --- /dev/null +++ b/src/com/koushikdutta/async/http/socketio/ReconnectCallback.java @@ -0,0 +1,5 @@ +package com.koushikdutta.async.http.socketio; + +public interface ReconnectCallback { + public void onReconnect(); +} \ No newline at end of file diff --git a/src/com/koushikdutta/async/http/socketio/SocketIOClient.java b/src/com/koushikdutta/async/http/socketio/SocketIOClient.java new file mode 100644 index 0000000..af704fe --- /dev/null +++ b/src/com/koushikdutta/async/http/socketio/SocketIOClient.java @@ -0,0 +1,184 @@ +package com.koushikdutta.async.http.socketio; + +import org.json.JSONArray; +import org.json.JSONObject; + +import android.os.Handler; +import android.text.TextUtils; + +import com.codebutler.android_websockets.WebSocketClient; +import com.koushikdutta.http.AsyncHttpClient; +import com.koushikdutta.http.AsyncHttpClient.SocketIORequest; + +public class SocketIOClient extends EventEmitter { + + boolean connected; + boolean disconnected; + Handler handler; + + private void emitRaw(int type, String message, Acknowledge acknowledge) { + connection.emitRaw(type, this, message, acknowledge); + } + + public void emit(String name, JSONArray args) { + emit(name, args, null); + } + + public void emit(final String message) { + emit(message, (Acknowledge) null); + } + + public void emit(final JSONObject jsonMessage) { + emit(jsonMessage, null); + } + + public void emit(String name, JSONArray args, Acknowledge acknowledge) { + final JSONObject event = new JSONObject(); + try { + event.put("name", name); + event.put("args", args); + emitRaw(5, event.toString(), acknowledge); + } catch (Exception e) { + } + } + + public void emit(final String message, Acknowledge acknowledge) { + emitRaw(3, message, acknowledge); + } + + public void emit(final JSONObject jsonMessage, Acknowledge acknowledge) { + emitRaw(4, jsonMessage.toString(), acknowledge); + } + + public static void connect(String uri, final ConnectCallback callback, final Handler handler) { + connect(new SocketIORequest(uri), callback, handler); + } + + ConnectCallback connectCallback; + + public static void connect(final SocketIORequest request, final ConnectCallback callback, final Handler handler) { + + final SocketIOConnection connection = new SocketIOConnection(handler, new AsyncHttpClient(), request); + + final ConnectCallback wrappedCallback = new ConnectCallback() { + @Override + public void onConnectCompleted(Exception ex, SocketIOClient client) { + if (ex != null || TextUtils.isEmpty(request.getEndpoint())) { + + client.handler = handler; + if (callback != null) { + callback.onConnectCompleted(ex, client); + } + + return; + } + + // remove the root client since that's not actually being used. + connection.clients.remove(client); + + // connect to the endpoint we want + client.of(request.getEndpoint(), new ConnectCallback() { + @Override + public void onConnectCompleted(Exception ex, SocketIOClient client) { + if (callback != null) { + callback.onConnectCompleted(ex, client); + } + } + }); + } + }; + + connection.clients.add(new SocketIOClient(connection, "", wrappedCallback)); + connection.reconnect(); + + } + + ErrorCallback errorCallback; + + public void setErrorCallback(ErrorCallback callback) { + errorCallback = callback; + } + + public ErrorCallback getErrorCallback() { + return errorCallback; + } + + DisconnectCallback disconnectCallback; + + public void setDisconnectCallback(DisconnectCallback callback) { + disconnectCallback = callback; + } + + public DisconnectCallback getDisconnectCallback() { + return disconnectCallback; + } + + ReconnectCallback reconnectCallback; + + public void setReconnectCallback(ReconnectCallback callback) { + reconnectCallback = callback; + } + + public ReconnectCallback getReconnectCallback() { + return reconnectCallback; + } + + JSONCallback jsonCallback; + + public void setJSONCallback(JSONCallback callback) { + jsonCallback = callback; + } + + public JSONCallback getJSONCallback() { + return jsonCallback; + } + + StringCallback stringCallback; + + public void setStringCallback(StringCallback callback) { + stringCallback = callback; + } + + public StringCallback getStringCallback() { + return stringCallback; + } + + SocketIOConnection connection; + String endpoint; + + private SocketIOClient(SocketIOConnection connection, String endpoint, + ConnectCallback callback) { + this.endpoint = endpoint; + this.connection = connection; + this.connectCallback = callback; + } + + public boolean isConnected() { + return connected && !disconnected && connection.isConnected(); + } + + public void disconnect() { + connection.disconnect(this); + final DisconnectCallback disconnectCallback = this.disconnectCallback; + if (disconnectCallback != null) { + handler.post(new Runnable() { + + @Override + public void run() { + disconnectCallback.onDisconnect(null); + + } + }); + + } + } + + public void of(String endpoint, ConnectCallback connectCallback) { + connection.connect(new SocketIOClient(connection, endpoint, connectCallback)); + } + + public WebSocketClient getWebSocket() { + return connection.webSocketClient; + } + +} diff --git a/src/com/koushikdutta/async/http/socketio/SocketIOConnection.java b/src/com/koushikdutta/async/http/socketio/SocketIOConnection.java new file mode 100644 index 0000000..f8efcdd --- /dev/null +++ b/src/com/koushikdutta/async/http/socketio/SocketIOConnection.java @@ -0,0 +1,445 @@ +package com.koushikdutta.async.http.socketio; + +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Hashtable; + +import org.json.JSONArray; +import org.json.JSONObject; + +import android.net.Uri; +import android.os.Handler; +import android.text.TextUtils; + +import com.codebutler.android_websockets.WebSocketClient; +import com.codebutler.android_websockets.WebSocketClient.Listener; +import com.koushikdutta.http.AsyncHttpClient; +import com.koushikdutta.http.AsyncHttpClient.SocketIORequest; + +/** + * Created by koush on 7/1/13. + */ +class SocketIOConnection { + + private Handler mHandler; + AsyncHttpClient httpClient; + int heartbeat; + ArrayList clients = new ArrayList(); + WebSocketClient webSocketClient; + SocketIORequest request; + + public SocketIOConnection(Handler handler, AsyncHttpClient httpClient, + SocketIORequest request) { + mHandler = handler; + this.httpClient = httpClient; + this.request = request; + } + + public boolean isConnected() { + return webSocketClient != null && webSocketClient.isConnected(); + } + + Hashtable acknowledges = new Hashtable(); + int ackCount; + + public void emitRaw(int type, SocketIOClient client, String message, Acknowledge acknowledge) { + String ack = ""; + if (acknowledge != null) { + String id = "" + ackCount++; + ack = id + "+"; + acknowledges.put(id, acknowledge); + } + webSocketClient.send(String.format("%d:%s:%s:%s", type, ack, client.endpoint, message)); + } + + public void connect(SocketIOClient client) { + clients.add(client); + webSocketClient.send(String.format("1::%s", client.endpoint)); + } + + public void disconnect(SocketIOClient client) { + clients.remove(client); + + // see if we can leave this endpoint completely + boolean needsEndpointDisconnect = true; + for (SocketIOClient other : clients) { + // if this is the default endpoint (which disconnects everything), + // or another client is using this endpoint, + // we can't disconnect + if (TextUtils.equals(other.endpoint, client.endpoint) + || TextUtils.isEmpty(client.endpoint)) { + needsEndpointDisconnect = false; + break; + } + } + + if (needsEndpointDisconnect) + webSocketClient.send(String.format("0::%s", client.endpoint)); + + // and see if we can disconnect the socket completely + if (clients.size() > 0) + return; + + webSocketClient.disconnect(); + webSocketClient = null; + } + + void reconnect() { + if (isConnected()) { + return; + } + + // initiate a session + httpClient.executeString(request, new AsyncHttpClient.StringCallback() { + @Override + public void onCompleted(final Exception e, String result) { + if (e != null) { + reportDisconnect(e); + return; + } + + try { + String[] parts = result.split(":"); + String session = parts[0]; + if (!"".equals(parts[1])) + heartbeat = Integer.parseInt(parts[1]) / 2 * 1000; + else + heartbeat = 0; + + String transportsLine = parts[3]; + String[] transports = transportsLine.split(","); + HashSet set = new HashSet(Arrays.asList(transports)); + if (!set.contains("websocket")) + throw new Exception("websocket not supported"); + + final String sessionUrl = Uri.parse(request.getUri()).buildUpon() + .appendPath("websocket").appendPath(session) + .build().toString(); + + SocketIOConnection.this.webSocketClient = new WebSocketClient(URI.create(sessionUrl), new Listener() { + + @Override + public void onMessage(byte[] data) { + //Do nothing + + } + + @Override + public void onMessage(String message) { + try { + // Log.d(TAG, "Message: " + message); + String[] parts = message.split(":", 4); + int code = Integer.parseInt(parts[0]); + switch (code) { + case 0: + // disconnect + webSocketClient.disconnect(); + reportDisconnect(null); + break; + case 1: + // connect + reportConnect(parts[2]); + break; + case 2: + // heartbeat + webSocketClient.send("2::"); + break; + case 3: { + // message + reportString(parts[2], parts[3], acknowledge(parts[1])); + break; + } + case 4: { + // json message + final String dataString = parts[3]; + final JSONObject jsonMessage = new JSONObject(dataString); + reportJson(parts[2], jsonMessage, acknowledge(parts[1])); + break; + } + case 5: { + final String dataString = parts[3]; + final JSONObject data = new JSONObject(dataString); + final String event = data.getString("name"); + final JSONArray args = data.optJSONArray("args"); + reportEvent(parts[2], event, args, acknowledge(parts[1])); + break; + } + case 6: + // ACK + final String[] ackParts = parts[3].split("\\+", 2); + Acknowledge ack = acknowledges.remove(ackParts[0]); + if (ack == null) + return; + JSONArray arguments = null; + if (ackParts.length == 2) + arguments = new JSONArray(ackParts[1]); + ack.acknowledge(arguments); + break; + case 7: + // error + reportError(parts[2], parts[3]); + break; + case 8: + // noop + break; + default: + throw new Exception("unknown code"); + } + } catch (Exception ex) { + webSocketClient.disconnect(); + webSocketClient = null; + reportDisconnect(ex); + } + + + } + + @Override + public void onError(Exception error) { + reportDisconnect(error); + } + + @Override + public void onDisconnect(int code, String reason) { + + reportDisconnect(new IOException(String.format("Disconnected code %d for reason %s", code, reason))); + } + + @Override + public void onConnect() { + reconnectDelay = 1000L; + setupHeartbeat(); + + } + }, null); + SocketIOConnection.this.webSocketClient.connect(); + + } catch (Exception ex) { + reportDisconnect(ex); + } + } + }); + + } + + void setupHeartbeat() { + final WebSocketClient ws = webSocketClient; + Runnable heartbeatRunner = new Runnable() { + @Override + public void run() { + if (heartbeat <= 0 || ws != webSocketClient || ws == null + || !ws.isConnected()) + return; + webSocketClient.send("2:::"); + + mHandler.postDelayed(this, heartbeat); + } + }; + heartbeatRunner.run(); + } + + private interface SelectCallback { + void onSelect(SocketIOClient client); + } + + private void select(String endpoint, SelectCallback callback) { + for (SocketIOClient client : clients) { + if (endpoint == null || TextUtils.equals(client.endpoint, endpoint)) { + callback.onSelect(client); + } + } + } + + private void delayReconnect() { + if (webSocketClient != null || clients.size() == 0) + return; + + // see if any client has disconnected, + // and that we need a reconnect + boolean disconnected = false; + for (SocketIOClient client : clients) { + if (client.disconnected) { + disconnected = true; + break; + } + } + + if (!disconnected) + return; + + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + reconnect(); + } + }, reconnectDelay); + reconnectDelay *= 2; + } + + long reconnectDelay = 1000L; + + private void reportDisconnect(final Exception ex) { + select(null, new SelectCallback() { + @Override + public void onSelect(final SocketIOClient client) { + if (client.connected) { + client.disconnected = true; + final DisconnectCallback closed = client.getDisconnectCallback(); + if (closed != null) { + mHandler.post(new Runnable() { + + @Override + public void run() { + closed.onDisconnect(ex); + + } + }); + + } + } else { + // client has never connected, this is a initial connect + // failure + final ConnectCallback callback = client.connectCallback; + if (callback != null) { + mHandler.post(new Runnable() { + + @Override + public void run() { + callback.onConnectCompleted(ex, client); + + } + }); + + } + } + } + }); + + delayReconnect(); + } + + private void reportConnect(String endpoint) { + select(endpoint, new SelectCallback() { + @Override + public void onSelect(SocketIOClient client) { + if (client.isConnected()) + return; + if (!client.connected) { + // normal connect + client.connected = true; + ConnectCallback callback = client.connectCallback; + if (callback != null) + callback.onConnectCompleted(null, client); + } else if (client.disconnected) { + // reconnect + client.disconnected = false; + ReconnectCallback callback = client.reconnectCallback; + if (callback != null) + callback.onReconnect(); + } else { + // double connect? + // assert false; + } + } + }); + } + + private void reportJson(String endpoint, final JSONObject jsonMessage, final Acknowledge acknowledge) { + select(endpoint, new SelectCallback() { + @Override + public void onSelect(SocketIOClient client) { + final JSONCallback callback = client.jsonCallback; + if (callback != null) { + mHandler.post(new Runnable() { + + @Override + public void run() { + callback.onJSON(jsonMessage, acknowledge); + + } + }); + } + + } + }); + } + + private void reportString(String endpoint, final String string, final Acknowledge acknowledge) { + select(endpoint, new SelectCallback() { + @Override + public void onSelect(SocketIOClient client) { + final StringCallback callback = client.stringCallback; + if (callback != null) { + mHandler.post(new Runnable() { + + @Override + public void run() { + callback.onString(string, acknowledge); + + } + }); + + } + } + }); + } + + private void reportEvent(String endpoint, final String event, final JSONArray arguments, final Acknowledge acknowledge) { + select(endpoint, new SelectCallback() { + @Override + public void onSelect(final SocketIOClient client) { + mHandler.post(new Runnable() { + + @Override + public void run() { + client.onEvent(event, arguments, acknowledge); + + } + }); + + } + }); + } + + private void reportError(String endpoint, final String error) { + select(endpoint, new SelectCallback() { + @Override + public void onSelect(SocketIOClient client) { + final ErrorCallback callback = client.errorCallback; + if (callback != null) { + + mHandler.post(new Runnable() { + + @Override + public void run() { + callback.onError(error); + + } + }); + + } + } + }); + } + + private Acknowledge acknowledge(final String messageId) { + if (TextUtils.isEmpty(messageId)) + return null; + + return new Acknowledge() { + @Override + public void acknowledge(JSONArray arguments) { + String data = ""; + if (arguments != null) + data += "+" + arguments.toString(); + webSocketClient.send(String.format("6:::%s%s", messageId, data)); + } + }; + } + + + +} diff --git a/src/com/koushikdutta/async/http/socketio/StringCallback.java b/src/com/koushikdutta/async/http/socketio/StringCallback.java new file mode 100644 index 0000000..7062b0f --- /dev/null +++ b/src/com/koushikdutta/async/http/socketio/StringCallback.java @@ -0,0 +1,5 @@ +package com.koushikdutta.async.http.socketio; + +public interface StringCallback { + public void onString(String string, Acknowledge acknowledge); +} \ No newline at end of file diff --git a/src/com/koushikdutta/async/util/HashList.java b/src/com/koushikdutta/async/util/HashList.java new file mode 100644 index 0000000..0036661 --- /dev/null +++ b/src/com/koushikdutta/async/util/HashList.java @@ -0,0 +1,29 @@ +package com.koushikdutta.async.util; + +import java.util.ArrayList; +import java.util.Hashtable; + +/** + * Created by koush on 5/27/13. + */ +public class HashList extends Hashtable> { + + private static final long serialVersionUID = 1L; + + public HashList() { + } + + public boolean contains(String key) { + ArrayList check = get(key); + return check != null && check.size() > 0; + } + + public void add(String key, T value) { + ArrayList ret = get(key); + if (ret == null) { + ret = new ArrayList(); + put(key, ret); + } + ret.add(value); + } +} diff --git a/src/com/koushikdutta/http/AsyncHttpClient.java b/src/com/koushikdutta/http/AsyncHttpClient.java new file mode 100644 index 0000000..c1855a5 --- /dev/null +++ b/src/com/koushikdutta/http/AsyncHttpClient.java @@ -0,0 +1,133 @@ +package com.koushikdutta.http; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import org.apache.http.HttpRequest; +import org.apache.http.HttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.message.BasicNameValuePair; + +import android.net.Uri; +import android.net.http.AndroidHttpClient; +import android.os.AsyncTask; + +import com.codebutler.android_websockets.WebSocketClient; + +/** + * + * Created by Vinay S Shenoy on 07/09/2013 + */ +public class AsyncHttpClient { + + public AsyncHttpClient() { + + } + + public static class SocketIORequest { + + private String mUri; + private String mEndpoint; + private List mHeaders; + + public SocketIORequest(String uri) { + this(uri, null); + } + + public SocketIORequest(String uri, String endpoint) { + this(uri, endpoint, null); + } + + public SocketIORequest(String uri, String endpoint, List headers) { + mUri = Uri.parse(uri).buildUpon().encodedPath("/socket.io/1/").build().toString(); + mEndpoint = endpoint; + mHeaders = headers; + } + + public String getUri() { + return mUri; + } + + public String getEndpoint() { + return mEndpoint; + } + + public List getHeaders() { + return mHeaders; + } + } + + public static interface StringCallback { + public void onCompleted(final Exception e, String result); + } + + public static interface WebSocketConnectCallback { + public void onCompleted(Exception ex, WebSocketClient webSocket); + } + + public void executeString(final SocketIORequest socketIORequest, final StringCallback stringCallback) { + + new AsyncTask() { + + @Override + protected Void doInBackground(Void... params) { + + AndroidHttpClient httpClient = AndroidHttpClient.newInstance("android-websockets-2.0"); + HttpPost post = new HttpPost(socketIORequest.getUri()); + addHeadersToRequest(post, socketIORequest.getHeaders()); + + try { + HttpResponse res = httpClient.execute(post); + String responseString = readToEnd(res.getEntity().getContent()); + + if (stringCallback != null) { + stringCallback.onCompleted(null, responseString); + } + + } catch (IOException e) { + + if (stringCallback != null) { + stringCallback.onCompleted(e, null); + } + } finally { + httpClient.close(); + httpClient = null; + } + return null; + } + + private void addHeadersToRequest(HttpRequest request, List headers) { + if (headers != null) { + Iterator it = headers.iterator(); + while (it.hasNext()) { + BasicNameValuePair header = it.next(); + request.addHeader(header.getName(), header.getValue()); + } + } + } + }.execute(); + } + + private byte[] readToEndAsArray(InputStream input) throws IOException { + DataInputStream dis = new DataInputStream(input); + byte[] stuff = new byte[1024]; + ByteArrayOutputStream buff = new ByteArrayOutputStream(); + int read = 0; + while ((read = dis.read(stuff)) != -1) { + buff.write(stuff, 0, read); + } + + return buff.toByteArray(); + } + + private String readToEnd(InputStream input) throws IOException { + return new String(readToEndAsArray(input)); + } + +}