diff --git a/.gitignore b/.gitignore index 81d81b4..926c55d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ -gen/ +bin +gen +.classpath +.project local.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..0667347 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ -# WebSocket client for Android +# WebSocket and Socket.IO client for Android -A very simple bare-minimum WebSocket client for Android. +A very simple bare-minimum 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). + +## WebSocket Usage ```java List extraHeaders = Arrays.asList( @@ -52,6 +54,59 @@ client.send(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }); client.disconnect(); ``` +## Socket.IO Usage + +```java +SocketIOClient client = new SocketIOClient(URI.create("wss://example.com"), new SocketIOClient.Handler() { + @Override + public void onConnect() { + Log.d(TAG, "Connected!"); + } + + @Override + public void on(String event, JSONArray arguments) { + Log.d(TAG, String.format("Got event %s: %s", event, arguments.toString())); + } + + @Override + public void onJSON(JSONObject json) { + try { + Log.d(TAG, String.format("Got JSON Object: %s", json.toString())); + } catch(JSONException e) { + } + } + + @Override + public void onMessage(String message) { + Log.d(TAG, String.format("Got message: %s", message)); + } + + @Override + public void onDisconnect(int code, String reason) { + Log.d(TAG, String.format("Disconnected! Code: %d Reason: %s", code, reason)); + } + + @Override + public void onError(Exception error) { + Log.e(TAG, "Error!", error); + } +}); + +client.connect(); + +// Later… +client.emit("Message"); //Message +JSONArray arguments = new JSONArray(); +arguments.put("first argument"); +JSONObject second = new JSONObject(); +second.put("dictionary", true); +client.emit(second); //JSON Message +arguments.put(second); +client.emit("hello", arguments); //Event +client.disconnect(); +``` + + ## TODO @@ -64,6 +119,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..1f83f2a 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 { @@ -380,4 +413,4 @@ public byte[] readBytes(int length) throws IOException { return buffer; } } -} \ No newline at end of file +} diff --git a/src/com/codebutler/android_websockets/SocketIOClient.java b/src/com/codebutler/android_websockets/SocketIOClient.java new file mode 100644 index 0000000..21e2593 --- /dev/null +++ b/src/com/codebutler/android_websockets/SocketIOClient.java @@ -0,0 +1,295 @@ +package com.codebutler.android_websockets; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.HashSet; + +import org.apache.http.HttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpUriRequest; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import android.net.http.AndroidHttpClient; +import android.os.Looper; +import android.util.Log; + +public class SocketIOClient { + public static interface Handler { + public void onConnect(); + + public void on(String event, JSONArray arguments); + + public void onDisconnect(int code, String reason); + + public void onJSON(JSONObject json); + + public void onMessage(String message); + + public void onError(Exception error); + } + + private static final String TAG = "SocketIOClient"; + + String mURL; + Handler mHandler; + String mSession; + int mHeartbeat; + WebSocketClient mClient; + + public SocketIOClient(URI uri, Handler handler) { + // remove trailing "/" from URI, in case user provided e.g. http://test.com/ + mURL = uri.toString().replaceAll("/$", "") + "/socket.io/1/"; + mHandler = handler; + } + + private static String downloadUriAsString(final HttpUriRequest req) throws IOException { + AndroidHttpClient client = AndroidHttpClient.newInstance("android-websockets"); + try { + HttpResponse res = client.execute(req); + return readToEnd(res.getEntity().getContent()); + } + finally { + client.close(); + } + } + + private static 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 static String readToEnd(InputStream input) throws IOException { + return new String(readToEndAsArray(input)); + } + + android.os.Handler mSendHandler; + Looper mSendLooper; + + public void emit(String name, JSONArray args) throws JSONException { + final JSONObject event = new JSONObject(); + event.put("name", name); + event.put("args", args); + Log.d(TAG, "Emitting event: " + event.toString()); + mSendHandler.post(new Runnable() { + @Override + public void run() { + mClient.send(String.format("5:::%s", event.toString())); + } + }); + } + + public void emit(final String message) { + mSendHandler.post(new Runnable() { + + @Override + public void run() { + mClient.send(String.format("3:::%s", message)); + } + }); + } + + public void emit(final JSONObject jsonMessage) { + + mSendHandler.post(new Runnable() { + + @Override + public void run() { + mClient.send(String.format("4:::%s", jsonMessage.toString())); + } + }); + } + + private void connectSession() throws URISyntaxException { + mClient = new WebSocketClient(new URI(mURL + "websocket/" + mSession), new WebSocketClient.Listener() { + @Override + public void onMessage(byte[] data) { + cleanup(); + mHandler.onError(new Exception("Unexpected binary data")); + } + + @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 1: + // connect + mHandler.onConnect(); + break; + case 2: + // heartbeat + mClient.send("2::"); + break; + case 3: { + // message + final String messageId = parts[1]; + final String dataString = parts[3]; + + if(!"".equals(messageId)) { + mSendHandler.post(new Runnable() { + + @Override + public void run() { + mClient.send(String.format("6:::%s", messageId)); + } + }); + } + mHandler.onMessage(dataString); + break; + } + case 4: { + //json message + final String messageId = parts[1]; + final String dataString = parts[3]; + + JSONObject jsonMessage = null; + + try { + jsonMessage = new JSONObject(dataString); + } catch(JSONException e) { + jsonMessage = new JSONObject(); + } + if(!"".equals(messageId)) { + mSendHandler.post(new Runnable() { + + @Override + public void run() { + mClient.send(String.format("6:::%s", messageId)); + } + }); + } + mHandler.onJSON(jsonMessage); + break; + } + case 5: { + final String messageId = parts[1]; + final String dataString = parts[3]; + JSONObject data = new JSONObject(dataString); + String event = data.getString("name"); + JSONArray args; + try { + args = data.getJSONArray("args"); + } catch (JSONException e) { + args = new JSONArray(); + } + if (!"".equals(messageId)) { + mSendHandler.post(new Runnable() { + @Override + public void run() { + mClient.send(String.format("6:::%s", messageId)); + } + }); + } + mHandler.on(event, args); + break; + } + case 6: + // ACK + break; + case 7: + // error + throw new Exception(message); + case 8: + // noop + break; + default: + throw new Exception("unknown code"); + } + } + catch (Exception ex) { + cleanup(); + onError(ex); + } + } + + @Override + public void onError(Exception error) { + cleanup(); + mHandler.onError(error); + } + + @Override + public void onDisconnect(int code, String reason) { + cleanup(); + // attempt reconnect with same session? + mHandler.onDisconnect(code, reason); + } + + @Override + public void onConnect() { + mSendHandler.postDelayed(new Runnable() { + @Override + public void run() { + mSendHandler.postDelayed(this, mHeartbeat); + mClient.send("2:::"); + } + }, mHeartbeat); + } + }, null); + mClient.connect(); + } + + public void disconnect() throws IOException { + cleanup(); + } + + private void cleanup() { + mClient.disconnect(); + mClient = null; + + mSendLooper.quit(); + mSendLooper = null; + mSendHandler = null; + } + + public void connect() { + if (mClient != null) + return; + new Thread() { + public void run() { + HttpPost post = new HttpPost(mURL); + try { + String line = downloadUriAsString(post); + String[] parts = line.split(":"); + mSession = parts[0]; + String heartbeat = parts[1]; + if (!"".equals(heartbeat)) + mHeartbeat = Integer.parseInt(heartbeat) / 2 * 1000; + 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"); + + Looper.prepare(); + mSendLooper = Looper.myLooper(); + mSendHandler = new android.os.Handler(); + + connectSession(); + + Looper.loop(); + } + catch (Exception e) { + mHandler.onError(e); + } + }; + }.start(); + } +} + diff --git a/src/com/codebutler/android_websockets/WebSocketClient.java b/src/com/codebutler/android_websockets/WebSocketClient.java index 7e3343c..82aadbe 100644 --- a/src/com/codebutler/android_websockets/WebSocketClient.java +++ b/src/com/codebutler/android_websockets/WebSocketClient.java @@ -36,6 +36,7 @@ public class WebSocketClient { private Handler mHandler; private List mExtraHeaders; private HybiParser mParser; + private boolean mConnected; private final Object mSendLock = new Object(); @@ -47,8 +48,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 +71,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,7 +81,7 @@ 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()); @@ -119,17 +121,21 @@ public void run() { 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); @@ -147,6 +153,7 @@ public void run() { try { mSocket.close(); mSocket = null; + mConnected = false; } catch (IOException ex) { Log.d(TAG, "Error while disconnecting", ex); mListener.onError(ex); @@ -164,6 +171,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;