net.hiroq.rxwsc.RxWebSocketClient.java Source code

Java tutorial

Introduction

Here is the source code for net.hiroq.rxwsc.RxWebSocketClient.java

Source

/**
 * MIT License
 * <p>
 * Copyright (c) 2016 Hiroki Oizumi
 * <p>
 * This library is ported from following libraries
 * 1. Copyright (c) 2010-2016 James Coglan
 * faye's faye-websocket-node
 * https://github.com/faye/faye-websocket-node
 * 2. Copyright (c) 2012 Eric Butler
 * codebutler's android-websockets.
 * https://github.com/codebutler/android-websockets
 * <p>
 * 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 the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * <p>
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 * <p>
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

package net.hiroq.rxwsc;

import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Pair;

import org.apache.http.Header;
import org.apache.http.HttpStatus;
import org.apache.http.StatusLine;
import org.apache.http.message.BasicLineParser;

import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.ConnectException;
import java.net.MalformedURLException;
import java.net.ProtocolException;
import java.net.Socket;
import java.security.KeyManagementException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;

import javax.net.SocketFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;

import rx.Observable;
import rx.Subscriber;
import rx.functions.Action0;

public class RxWebSocketClient {
    /**
     * Streaming EventTypes
     */
    public enum EventType {
        CONNECT, MESSAGE_STRING, MESSAGE_BINARY, DISCONNECT,
    }

    public static class Event {
        /**
         * EventType
         */
        private EventType mType = null;

        /**
         * Payload byte data from WebSocketServer
         */
        private byte[] mBytes = null;

        /**
         * Payload String data from WebSocketServer
         */
        private String mString = null;

        /**
         * Constructor
         *
         * @param type
         */
        Event(EventType type) {
            this.mType = type;
        }

        /**
         * Constructor
         *
         * @param type
         * @param bytes
         */
        Event(EventType type, byte[] bytes) {
            this.mType = type;
            this.mBytes = bytes;
        }

        /**
         * Constructor
         *
         * @param type
         * @param string
         */
        Event(EventType type, String string) {
            this.mType = type;
            this.mString = string;
        }

        /**
         * Get EventType value
         *
         * @return
         */
        public EventType getType() {
            return mType;
        }

        /**
         * Get payload byte data.
         * It will return valid data if EventType is MESSAGE_BINARY.
         * When the other EventTypes, it will return null.
         *
         * @return string value. Return null if no received data,
         */
        public byte[] getBytes() {
            return mBytes;
        }

        /**
         * Get payload String data.
         * It will return valid data if EventType is MESSAGE_STRING.
         * When the other EventTypes, it will return null.
         *
         * @return string value. Return null if no received data,
         */
        public String getString() {
            return mString;
        }
    }

    /**
     * SSL/TSL TrustedManagers
     */
    private static TrustManager[] sTrustManagers;

    /**
     * Connection Uri
     */
    private Uri mUri;

    /**
     * Raw Socket Object
     */
    private Socket mSocket;

    /**
     * Thread with message queue
     */
    private HandlerThread mHandlerThread;

    /**
     * Handler
     */
    private Handler mHandler;

    /**
     * Additional HttpHeader
     */
    private List<Pair<String, String>> mExtraHeaders;

    /**
     * WebSocket Message Parser
     */
    private HybiParser mParser;

    /**
     * RxJava Subscriber
     */
    private Subscriber<? super Event> mSubscriber;

    /**
     * Object for mutex
     */
    private Object mSendLock = new Object();

    /**
     * Connection Flag
     */
    private boolean mIsConnected = false;

    /**
     * Connect to WebSocketServer with additional Header.
     * When unsubscribe is called, the observable will disconnect automatically.
     * <p>
     * Caution: This method run on same thread of caller. So if you want to run on NOT UI THREAD,
     * you have to use subscribeOn to specify thread model.
     *
     * @param uri
     * @param extraHeaders
     * @return
     */
    public Observable<Event> connect(Uri uri, List<Pair<String, String>> extraHeaders) {
        this.disconnect(false);

        this.mUri = uri;
        this.mExtraHeaders = extraHeaders;
        this.mParser = new HybiParser(this);

        this.mHandlerThread = new HandlerThread(getClass().getName());
        this.mHandlerThread.start();
        this.mHandler = new Handler(mHandlerThread.getLooper());

        return Observable.create(new Observable.OnSubscribe<Event>() {
            @Override
            public void call(Subscriber<? super Event> subscriber) {
                try {
                    mSubscriber = subscriber;
                    String secret = createSecret();
                    String scheme = mUri.getScheme();

                    // uri have invalid scheme throw MalformedURLException
                    if (scheme == null || !(scheme.equals("ws") || scheme.equals("wss"))) {
                        new MalformedURLException("Url scheme has to be specified as \"ws\" or \"wss\".");
                    }

                    int port = (mUri.getPort() != -1) ? mUri.getPort() : (scheme.equals("wss") ? 443 : 80);
                    String path = TextUtils.isEmpty(mUri.getPath()) ? "/" : mUri.getPath();
                    if (!TextUtils.isEmpty(mUri.getQuery())) {
                        path += "?" + mUri.getQuery();
                    }

                    String originScheme = scheme.equals("wss") ? "https" : "http";
                    Uri origin = Uri.parse(originScheme + "://" + mUri.getHost());

                    SocketFactory factory = scheme.equals("wss") ? getSSLSocketFactory()
                            : SocketFactory.getDefault();
                    mSocket = factory.createSocket(mUri.getHost(), port);

                    PrintWriter out = new PrintWriter(mSocket.getOutputStream());
                    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: " + secret + "\r\n");
                    out.print("Sec-WebSocket-Version: 13\r\n");
                    if (mExtraHeaders != null) {
                        for (Pair<String, String> pair : mExtraHeaders) {
                            out.print(String.format("%s: %s\r\n", pair.first, pair.second));
                        }
                    }
                    out.print("\r\n");
                    out.flush();

                    HybiParser.HappyDataInputStream stream = new HybiParser.HappyDataInputStream(
                            mSocket.getInputStream());

                    // Read HTTP response status line.
                    StatusLine statusLine = parseStatusLine(readLine(stream));
                    if (statusLine == null) {
                        throw new ConnectException("Received no reply from server.");
                    } else if (statusLine.getStatusCode() != HttpStatus.SC_SWITCHING_PROTOCOLS) {
                        throw new ProtocolException(
                                "Server sent invalid response code " + statusLine.getStatusCode()
                                        + ". WebSocket server must return " + HttpStatus.SC_SWITCHING_PROTOCOLS);
                    }

                    // Read HTTP response headers.
                    String line;
                    boolean validated = false;

                    while (!TextUtils.isEmpty(line = readLine(stream))) {
                        Header header = parseHeader(line);
                        if (header.getName().equals("Sec-WebSocket-Accept")) {
                            String expected = createSecretValidation(secret);
                            String actual = header.getValue().trim();

                            if (!expected.equals(actual)) {
                                throw new ProtocolException("Bad Sec-WebSocket-Accept header value.");
                            }

                            validated = true;
                        }
                    }

                    if (!validated) {
                        throw new ProtocolException("No Sec-WebSocket-Accept header.");
                    }

                    mIsConnected = true;
                    emitterOnNext(new Event(EventType.CONNECT));

                    // Now decode websocket frames.
                    mParser.start(stream);
                } catch (Exception e) {
                    emitterOnError(e);
                }
            }
        }).doOnUnsubscribe(new Action0() {
            @Override
            public void call() {
                RxWebSocketClient.this.disconnect(false);
            }
        });
    }

    /**
     * Connect to WebSocketServer.
     *
     * @param uri
     * @return
     */
    public Observable<Event> connect(Uri uri) {
        return connect(uri, null);
    }

    /**
     * Send string data to WebSocketServer.
     *
     * @param message
     */
    public void send(String message) {
        sendFrame(mParser.frame(message));
    }

    /**
     * Send raw data to WebSocketServer.
     *
     * @param message
     */
    public void send(byte[] message) {
        sendFrame(mParser.frame(message));
    }

    /**
     * Disconnect WebSocket, emit onNext with EventType.DISCONNECT and finally onComplete to Streaming
     */
    public void disconnect() {
        disconnect(true);
    }

    private void disconnect(boolean emitt) {
        if (!mIsConnected) {
            return;
        }

        mIsConnected = false;
        if (emitt) {
            emitterOnNext(new Event(EventType.DISCONNECT));
            emitterOnCompleted();
        }
        if (mSocket != null) {
            mHandler.post(new Runnable() {
                @Override
                public void run() {
                    try {
                        mParser.stop();
                        mSocket.close();
                        mHandlerThread.join();
                    } catch (Exception e) {
                        // At this time, ignore any exceptions
                    }
                }
            });
        }
    }

    /**
     * Parse HttpStatusLine
     *
     * @param line
     * @return
     */
    private StatusLine parseStatusLine(String line) {
        if (TextUtils.isEmpty(line)) {
            return null;
        }
        return BasicLineParser.parseStatusLine(line, new BasicLineParser());
    }

    /**
     * Parse HttpHeader
     *
     * @param line
     * @return
     */
    private Header parseHeader(String line) {
        return BasicLineParser.parseHeader(line, new BasicLineParser());
    }

    /**
     * Read string data from parsed WebSocket message.
     *
     * @param reader
     * @return
     * @throws IOException
     */
    private String readLine(HybiParser.HappyDataInputStream reader) throws IOException {
        int readChar = reader.read();
        if (readChar == -1) {
            return null;
        }
        StringBuilder string = new StringBuilder("");
        while (readChar != '\n') {
            if (readChar != '\r') {
                string.append((char) readChar);
            }

            readChar = reader.read();
            if (readChar == -1) {
                return null;
            }
        }
        return string.toString();
    }

    /**
     * create Secret specified RFC6455.
     *
     * @return
     */
    private String createSecret() {
        byte[] nonce = new byte[16];
        for (int i = 0; i < 16; i++) {
            nonce[i] = (byte) (Math.random() * 256);
        }
        return Base64.encodeToString(nonce, Base64.DEFAULT).trim();
    }

    /**
     * create SecretValidation specified RFC6455
     *
     * @param secret
     * @return
     */
    private String createSecretValidation(String secret) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-1");
            md.update((secret + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").getBytes());
            return Base64.encodeToString(md.digest(), Base64.DEFAULT).trim();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Set SSL/TSL TrustedManager
     *
     * @param tm
     */
    public static void setTrustManagers(TrustManager[] tm) {
        sTrustManagers = tm;
    }

    /**
     * Init SSL/TSL context and get SocketFactory
     *
     * @return
     * @throws NoSuchAlgorithmException
     * @throws KeyManagementException
     */
    protected SSLSocketFactory getSSLSocketFactory() throws NoSuchAlgorithmException, KeyManagementException {
        SSLContext context = SSLContext.getInstance("TLS");
        context.init(null, sTrustManagers, null);
        return context.getSocketFactory();
    }

    /**
     * send frame data to SocketStream
     *
     * @param frame
     */
    void sendFrame(final byte[] frame) {
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                try {
                    synchronized (mSendLock) {
                        if (mSocket == null) {
                            throw new ConnectException("Socket not connected");
                        }
                        OutputStream outputStream = mSocket.getOutputStream();
                        outputStream.write(frame);
                        outputStream.flush();
                    }
                } catch (IOException e) {
                    emitterOnError(e);
                }
            }
        });
    }

    /**
     * Emit onNext to Streaming
     */
    void emitterOnNext(Event event) {
        if (mSubscriber != null) {
            mSubscriber.onNext(event);
        }
    }

    /**
     * Emit onError to Streaming
     *
     * @param e
     */
    void emitterOnError(Throwable e) {
        mIsConnected = false;
        if (mSubscriber != null) {
            mSubscriber.onError(e);
        }
    }

    /**
     * Emit onComplete to Streaming
     */
    void emitterOnCompleted() {
        mIsConnected = false;
        if (mSubscriber != null) {
            mSubscriber.onCompleted();
        }
    }

    /**
     * get connection status
     *
     * @return
     */
    public boolean isConnected() {
        return mIsConnected;
    }
}