WsClient.java

// SPDX-License-Identifier: Apache-2.0
package com.shengzhiai.yugu.stcompat.internal;

import android.annotation.SuppressLint;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.EOFException;
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.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.security.SecureRandom;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SNIServerName;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;

/**
 * RFC 6455 WebSocket client of the compat realtime channel (DESIGN 14.3), part of this AAR so the
 * drop-in keeps zero runtime dependencies.
 *
 * <ul>
 * <li>ws and wss; every address the host resolves to is tried with the connect timeout</li>
 * <li>TLS through an SSLSocketFactory ({@link HttpsURLConnection#getDefaultSSLSocketFactory()} by
 * default) with SNI for host names; the certificate name is checked with
 * {@link HttpsURLConnection#getDefaultHostnameVerifier()}. The default verifier of a plain JVM
 * rejects every name (HttpsURLConnection checks names itself before asking it), so there the TLS
 * handshake checks the name instead (endpoint identification HTTPS)</li>
 * <li>opening handshake with the Sec-WebSocket-Accept check; an extension or subprotocol that was
 * not offered fails the handshake</li>
 * <li>client frames masked with a fresh random key; text, binary and continuation frames, ping
 * answered with pong, close handshake</li>
 * <li>an inbound frame or message larger than {@link #setMaxMessageBytes} (128 KB by default) closes
 * the connection with 1009, invalid UTF-8 text with 1007, any other protocol violation with 1002</li>
 * </ul>
 *
 * Listener callbacks run on one reader thread. A connection ends with exactly one onClosed or
 * onFailure, unless {@link #close} or {@link #abort} was called first; then it ends silently.
 */
public final class WsClient implements WsConnection {
    public static final int OP_CONTINUATION = 0x0;
    public static final int OP_TEXT = 0x1;
    public static final int OP_BINARY = 0x2;
    public static final int OP_CLOSE = 0x8;
    public static final int OP_PING = 0x9;
    public static final int OP_PONG = 0xA;

    /** Default cap of inbound frames and messages: the platform's frame limit (DESIGN 14.3). */
    public static final int DEFAULT_MAX_MESSAGE_BYTES = 128 * 1024;
    /** Status line and headers of the handshake answer. */
    static final int MAX_HANDSHAKE_BYTES = 16 * 1024;
    /** How long a client close waits for the server's close frame. */
    static final long CLOSE_WAIT_MS = 1000;
    static final String ACCEPT_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
    /** Class of the JDK's default HostnameVerifier, which answers false for every name. */
    static final String JDK_DEFAULT_VERIFIER = "javax.net.ssl.HttpsURLConnection$DefaultHostnameVerifier";

    /**
     * {@link Options#hostnameVerifier} value that leaves the name check to the TLS handshake
     * (endpoint identification HTTPS), as done for the JDK's default verifier. Never called.
     */
    public static final HostnameVerifier HANDSHAKE_CHECKS_NAME = new HostnameVerifier() {
        @Override
        public boolean verify(String hostname, javax.net.ssl.SSLSession session) {
            return false;
        }
    };

    private static final SecureRandom RANDOM = new SecureRandom();
    private static final byte[] EMPTY = new byte[0];

    /** The server answered the opening handshake with a status other than 101. */
    public static final class HandshakeException extends IOException {
        private static final long serialVersionUID = 1L;
        public final int status;
        /** Retry-After of the answer in ms, -1 when absent. */
        public final long retryAfterMs;

        HandshakeException(int status, long retryAfterMs) {
            super("WebSocket handshake answered HTTP " + status);
            this.status = status;
            this.retryAfterMs = retryAfterMs;
        }
    }

    /** The peer violated the protocol; the connection was closed with {@link #closeCode}. */
    public static final class ProtocolError extends IOException {
        private static final long serialVersionUID = 1L;
        public final int closeCode;

        ProtocolError(int closeCode, String message) {
            super(message);
            this.closeCode = closeCode;
        }
    }

    /** Resolves a host to every address; replaced in tests. */
    public interface Resolver {
        InetAddress[] resolve(String host) throws IOException;
    }

    public static final Resolver SYSTEM_RESOLVER = new Resolver() {
        @Override
        public InetAddress[] resolve(String host) throws IOException {
            return InetAddress.getAllByName(host);
        }
    };

    /** Connection options. */
    public static final class Options {
        /** ws or wss URL with path and query. */
        public String url;
        public final Map<String, String> headers = new LinkedHashMap<String, String>();
        public int connectTimeoutMs = CompatConfig.DEFAULT_CONNECT_TIMEOUT_MS;
        public int readTimeoutMs = CompatConfig.DEFAULT_READ_TIMEOUT_MS;
        /** null: {@link HttpsURLConnection#getDefaultSSLSocketFactory()}. */
        public SSLSocketFactory sslSocketFactory;
        /** null: {@link HttpsURLConnection#getDefaultHostnameVerifier()}. */
        public HostnameVerifier hostnameVerifier;
        /** null: {@link #SYSTEM_RESOLVER}. */
        public Resolver resolver;
        public int maxMessageBytes = DEFAULT_MAX_MESSAGE_BYTES;
    }

    /** Connector of {@link CompatConfig#wsConnector()} by default. */
    public static final Connector CONNECTOR = new Connector() {
        @Override
        public WsConnection connect(String url, Map<String, String> headers, int connectTimeoutMs, int readTimeoutMs,
                                    Listener listener) throws IOException {
            Options o = new Options();
            o.url = url;
            if (headers != null) {
                o.headers.putAll(headers);
            }
            o.connectTimeoutMs = connectTimeoutMs;
            o.readTimeoutMs = readTimeoutMs;
            return WsClient.connect(o, listener);
        }
    };

    private final Socket raw;
    private final Socket socket;
    private final InputStream in;
    private final OutputStream out;
    private final Listener listener;
    private final Object writeLock = new Object();
    private final CountDownLatch readerDone = new CountDownLatch(1);
    private volatile int maxMessageBytes;
    /** close or abort was called: no further callbacks. */
    private volatile boolean silenced;
    /** Guarded by writeLock. */
    private boolean closeSent;
    private Thread reader;
    // reader thread only
    private ByteArrayOutputStream fragments;
    private int fragmentsOpcode;
    private int serverCloseCode;
    private String serverCloseReason;

    private WsClient(Socket raw, Socket socket, InputStream in, OutputStream out, Listener listener, int maxMessageBytes) {
        this.raw = raw;
        this.socket = socket;
        this.in = in;
        this.out = out;
        this.listener = listener;
        this.maxMessageBytes = maxMessageBytes;
    }

    // ================================================================ connect

    /**
     * Connects, completes the opening handshake and starts the reader thread.
     *
     * @throws HandshakeException when the server answers with a status other than 101
     * @throws ProtocolError when the answer is not a valid WebSocket handshake
     * @throws IOException on connect, TLS or I/O failure
     */
    public static WsClient connect(Options o, Listener listener) throws IOException {
        Target t = Target.parse(o.url);
        int connectTimeout = Math.max(1, o.connectTimeoutMs);
        Socket raw = openSocket(t, o.resolver != null ? o.resolver : SYSTEM_RESOLVER, connectTimeout);
        Socket s = raw;
        boolean ok = false;
        try {
            raw.setTcpNoDelay(true);
            // TLS and the handshake answer are bounded by the connect timeout, frames by the read timeout
            raw.setSoTimeout(connectTimeout);
            if (t.secure) {
                s = tls(raw, t, o);
            }
            InputStream in = new BufferedInputStream(s.getInputStream(), 16 * 1024);
            OutputStream out = s.getOutputStream();
            String key = Codec.base64(randomBytes(16));
            out.write(requestHead(t, key, o.headers).getBytes(Codec.UTF_8));
            out.flush();
            readHandshakeAnswer(in, key);
            s.setSoTimeout(Math.max(1, o.readTimeoutMs));
            WsClient c = new WsClient(raw, s, in, out, listener, o.maxMessageBytes > 0 ? o.maxMessageBytes
                    : DEFAULT_MAX_MESSAGE_BYTES);
            c.startReader();
            ok = true;
            return c;
        } finally {
            if (!ok) {
                closeQuietly(s);
                closeQuietly(raw);
            }
        }
    }

    /** Scheme, host, port and request target of a ws or wss URL. */
    static final class Target {
        final boolean secure;
        final String host;
        final int port;
        final String resource;
        final String hostHeader;

        private Target(boolean secure, String host, int port, String resource, String hostHeader) {
            this.secure = secure;
            this.host = host;
            this.port = port;
            this.resource = resource;
            this.hostHeader = hostHeader;
        }

        /** Never puts the URL into an exception message: its query carries the signature. */
        static Target parse(String url) throws IOException {
            URI u;
            try {
                u = new URI(url);
            } catch (URISyntaxException e) {
                throw new IOException("invalid WebSocket URL");
            }
            String scheme = u.getScheme() == null ? "" : u.getScheme().toLowerCase(Locale.ROOT);
            boolean secure;
            if ("wss".equals(scheme)) {
                secure = true;
            } else if ("ws".equals(scheme)) {
                secure = false;
            } else {
                throw new IOException("WebSocket URL needs the ws or wss scheme, not '" + scheme + "'");
            }
            String host = u.getHost();
            if (host == null || host.isEmpty()) {
                throw new IOException("WebSocket URL has no host");
            }
            if (host.startsWith("[") && host.endsWith("]")) {
                host = host.substring(1, host.length() - 1);
            }
            int defaultPort = secure ? 443 : 80;
            int port = u.getPort() > 0 ? u.getPort() : defaultPort;
            String path = u.getRawPath();
            if (path == null || path.isEmpty()) {
                path = "/";
            }
            String query = u.getRawQuery();
            String hostHeader = (host.indexOf(':') >= 0 ? "[" + host + "]" : host) + (port == defaultPort ? "" : ":" + port);
            return new Target(secure, host, port, query == null ? path : path + "?" + query, hostHeader);
        }
    }

    /** Tries every resolved address in order with the connect timeout; throws the last failure. */
    static Socket openSocket(Target t, Resolver resolver, int connectTimeoutMs) throws IOException {
        InetAddress[] addresses = resolver.resolve(t.host);
        if (addresses == null || addresses.length == 0) {
            throw new UnknownHostException(t.host);
        }
        IOException last = null;
        for (InetAddress a : addresses) {
            Socket s = new Socket();
            try {
                s.connect(new InetSocketAddress(a, t.port), connectTimeoutMs);
                return s;
            } catch (IOException e) {
                closeQuietly(s);
                if (last != null) {
                    e.addSuppressed(last);
                }
                last = e;
                YLog.d("WebSocket connect to " + a.getHostAddress() + " port " + t.port + " failed: " + e);
            }
        }
        throw last;
    }

    private static SSLSocket tls(Socket raw, Target t, Options o) throws IOException {
        SSLSocketFactory f = o.sslSocketFactory != null ? o.sslSocketFactory : HttpsURLConnection.getDefaultSSLSocketFactory();
        SSLSocket s = (SSLSocket) f.createSocket(raw, t.host, t.port, true);
        s.setSoTimeout(raw.getSoTimeout());
        HostnameVerifier v = o.hostnameVerifier != null ? o.hostnameVerifier : HttpsURLConnection.getDefaultHostnameVerifier();
        boolean handshakeChecksName = v == null || v == HANDSHAKE_CHECKS_NAME || JDK_DEFAULT_VERIFIER.equals(v.getClass().getName());
        if (!configureTls(s, t.host, handshakeChecksName)) {
            throw new SSLPeerUnverifiedException("no way to check the certificate name of " + t.host);
        }
        s.startHandshake();
        if (!handshakeChecksName && !v.verify(t.host, s.getSession())) {
            throw new SSLPeerUnverifiedException("certificate does not match " + t.host);
        }
        return s;
    }

    /**
     * SNI for host names (SNIHostName on Android 7 and later and on the JVM, setHostname of the
     * platform socket on Android 5 and 6) and, when asked, endpoint identification HTTPS. Returns
     * false only when endpoint identification was asked for and is not available.
     */
    @SuppressLint("NewApi")
    static boolean configureTls(SSLSocket s, String host, boolean endpointIdentification) {
        SSLParameters p = s.getSSLParameters();
        boolean sniSet = false;
        if (isSniHostname(host)) {
            try {
                p.setServerNames(Collections.<SNIServerName>singletonList(new SNIHostName(host)));
                sniSet = true;
            } catch (NoClassDefFoundError | NoSuchMethodError | IllegalArgumentException e) {
                // Android 5 and 6 have no SNIHostName; names SNI rejects are sent without SNI
            }
            if (!sniSet) {
                try {
                    s.getClass().getMethod("setHostname", String.class).invoke(s, host);
                } catch (Exception | LinkageError ignored) {
                    // this socket implementation sends no SNI
                }
            }
        }
        boolean identification = false;
        if (endpointIdentification) {
            try {
                p.setEndpointIdentificationAlgorithm("HTTPS");
                identification = true;
            } catch (NoSuchMethodError e) {
                identification = false;
            }
        }
        if (sniSet || identification) {
            s.setSSLParameters(p);
        }
        return !endpointIdentification || identification;
    }

    /** True for a DNS name; IP literals get no SNI. */
    static boolean isSniHostname(String host) {
        if (host == null || host.isEmpty() || host.indexOf(':') >= 0) {
            return false;
        }
        for (int i = 0; i < host.length(); i++) {
            char c = host.charAt(i);
            if (c != '.' && (c < '0' || c > '9')) {
                return true;
            }
        }
        return false;
    }

    static String requestHead(Target t, String key, Map<String, String> headers) throws IOException {
        StringBuilder sb = new StringBuilder(512);
        sb.append("GET ").append(t.resource).append(" HTTP/1.1\r\n");
        sb.append("Host: ").append(t.hostHeader).append("\r\n");
        sb.append("Upgrade: websocket\r\n");
        sb.append("Connection: Upgrade\r\n");
        sb.append("Sec-WebSocket-Key: ").append(key).append("\r\n");
        sb.append("Sec-WebSocket-Version: 13\r\n");
        if (headers != null) {
            for (Map.Entry<String, String> h : headers.entrySet()) {
                String name = h.getKey();
                String value = h.getValue() == null ? "" : h.getValue();
                if (name == null || name.isEmpty() || hasLineBreak(name) || hasLineBreak(value)) {
                    throw new IOException("invalid handshake header " + name);
                }
                sb.append(name).append(": ").append(value).append("\r\n");
            }
        }
        return sb.append("\r\n").toString();
    }

    private static boolean hasLineBreak(String s) {
        return s.indexOf('\r') >= 0 || s.indexOf('\n') >= 0;
    }

    /** Reads the status line and headers of the answer and checks them. Frames that follow stay in {@code in}. */
    static void readHandshakeAnswer(InputStream in, String key) throws IOException {
        int[] budget = {MAX_HANDSHAKE_BYTES};
        String status = readLine(in, budget);
        String[] parts = status.split(" ", 3);
        int code = -1;
        if (parts.length >= 2 && parts[0].startsWith("HTTP/")) {
            try {
                code = Integer.parseInt(parts[1].trim());
            } catch (NumberFormatException ignored) {
                code = -1;
            }
        }
        if (code < 0) {
            throw new ProtocolError(1002, "the WebSocket handshake answer is not HTTP");
        }
        Map<String, String> h = new LinkedHashMap<String, String>();
        while (true) {
            String line = readLine(in, budget);
            if (line.isEmpty()) {
                break;
            }
            int colon = line.indexOf(':');
            if (colon <= 0) {
                continue;
            }
            String name = line.substring(0, colon).trim().toLowerCase(Locale.ROOT);
            String value = line.substring(colon + 1).trim();
            String prev = h.get(name);
            h.put(name, prev == null ? value : prev + ", " + value);
        }
        if (code != 101) {
            throw new HandshakeException(code, ErrorMapper.parseRetryAfter(h.get("retry-after"), System.currentTimeMillis()));
        }
        if (!"websocket".equalsIgnoreCase(h.get("upgrade"))) {
            throw new ProtocolError(1002, "the handshake answer has no Upgrade: websocket");
        }
        if (!hasToken(h.get("connection"), "upgrade")) {
            throw new ProtocolError(1002, "the handshake answer has no Connection: Upgrade");
        }
        if (!acceptFor(key).equals(h.get("sec-websocket-accept"))) {
            throw new ProtocolError(1002, "Sec-WebSocket-Accept does not match the key");
        }
        if (h.containsKey("sec-websocket-extensions")) {
            throw new ProtocolError(1002, "the server selected an extension that was not offered");
        }
        if (h.containsKey("sec-websocket-protocol")) {
            throw new ProtocolError(1002, "the server selected a subprotocol that was not offered");
        }
    }

    private static String readLine(InputStream in, int[] budget) throws IOException {
        ByteArrayOutputStream b = new ByteArrayOutputStream(80);
        while (true) {
            int c = in.read();
            if (c < 0) {
                throw new EOFException("connection closed during the WebSocket handshake");
            }
            if (--budget[0] < 0) {
                throw new ProtocolError(1002, "the WebSocket handshake answer exceeds " + MAX_HANDSHAKE_BYTES + " bytes");
            }
            if (c == '\n') {
                break;
            }
            b.write(c);
        }
        byte[] a = b.toByteArray();
        int n = a.length > 0 && a[a.length - 1] == '\r' ? a.length - 1 : a.length;
        return new String(a, 0, n, Codec.US_ASCII);
    }

    private static boolean hasToken(String value, String token) {
        if (value == null) {
            return false;
        }
        for (String t : value.split(",")) {
            if (token.equalsIgnoreCase(t.trim())) {
                return true;
            }
        }
        return false;
    }

    /** Sec-WebSocket-Accept for a key: Base64(SHA-1(key + GUID)). */
    public static String acceptFor(String key) {
        return Codec.base64(Codec.digest("SHA-1", (key + ACCEPT_GUID).getBytes(Codec.US_ASCII)));
    }

    // ================================================================ reading

    private void startReader() {
        reader = new Thread(new Runnable() {
            @Override
            public void run() {
                readLoop();
            }
        }, "yugu-stcompat-ws-reader");
        reader.setDaemon(true);
        reader.start();
    }

    private void readLoop() {
        IOException failure = null;
        try {
            readFrames();
        } catch (ProtocolError e) {
            failure = e;
            sendCloseQuietly(e.closeCode, e.getMessage());
        } catch (IOException e) {
            failure = e;
        } catch (RuntimeException e) {
            failure = new IOException("WebSocket reader failed: " + e, e);
        } finally {
            closeQuietly(socket);
            closeQuietly(raw);
            readerDone.countDown();
        }
        if (silenced) {
            return;
        }
        try {
            if (failure == null) {
                listener.onClosed(serverCloseCode, serverCloseReason);
            } else {
                listener.onFailure(failure);
            }
        } catch (RuntimeException e) {
            YLog.w("WebSocket listener failed", e);
        }
    }

    /** Reads frames until the server's close frame; throws on failure. */
    private void readFrames() throws IOException {
        while (true) {
            int b0 = readByte();
            int b1 = readByte();
            boolean fin = (b0 & 0x80) != 0;
            int op = b0 & 0x0F;
            if ((b0 & 0x70) != 0) {
                throw new ProtocolError(1002, "reserved bits set without a negotiated extension");
            }
            if ((b1 & 0x80) != 0) {
                throw new ProtocolError(1002, "the server masked a frame");
            }
            long len = b1 & 0x7F;
            if (len == 126) {
                len = (readByte() << 8) | readByte();
            } else if (len == 127) {
                len = 0;
                for (int i = 0; i < 8; i++) {
                    len = (len << 8) | readByte();
                }
                if (len < 0) {
                    throw new ProtocolError(1002, "frame length out of range");
                }
            }
            boolean control = (op & 0x08) != 0;
            if (control) {
                if (op != OP_CLOSE && op != OP_PING && op != OP_PONG) {
                    throw new ProtocolError(1002, "unknown control opcode " + op);
                }
                if (!fin) {
                    throw new ProtocolError(1002, "fragmented control frame");
                }
                if (len > 125) {
                    throw new ProtocolError(1002, "control frame longer than 125 bytes");
                }
            } else {
                if (op != OP_CONTINUATION && op != OP_TEXT && op != OP_BINARY) {
                    throw new ProtocolError(1002, "unknown opcode " + op);
                }
                if (op == OP_CONTINUATION && fragments == null) {
                    throw new ProtocolError(1002, "continuation frame without a message");
                }
                if (op != OP_CONTINUATION && fragments != null) {
                    throw new ProtocolError(1002, "new message before the fragmented one ended");
                }
                long total = len + (op == OP_CONTINUATION ? fragments.size() : 0);
                int max = maxMessageBytes;
                if (total > max) {
                    throw new ProtocolError(1009, "inbound message of more than " + max + " bytes");
                }
            }
            byte[] payload = readFully((int) len);
            switch (op) {
                case OP_PING:
                    sendFrame(OP_PONG, payload, 0, payload.length);
                    break;
                case OP_PONG:
                    break;
                case OP_CLOSE:
                    onCloseFrame(payload);
                    return;
                case OP_CONTINUATION:
                    fragments.write(payload, 0, payload.length);
                    if (fin) {
                        byte[] message = fragments.toByteArray();
                        int messageOp = fragmentsOpcode;
                        fragments = null;
                        deliver(messageOp, message);
                    }
                    break;
                default:
                    if (fin) {
                        deliver(op, payload);
                    } else {
                        fragments = new ByteArrayOutputStream(Math.max(256, payload.length * 2));
                        fragments.write(payload, 0, payload.length);
                        fragmentsOpcode = op;
                    }
                    break;
            }
        }
    }

    private void onCloseFrame(byte[] payload) throws IOException {
        int code = 1005;
        String reason = "";
        if (payload.length == 1) {
            throw new ProtocolError(1002, "close frame with a 1 byte payload");
        }
        if (payload.length >= 2) {
            code = ((payload[0] & 0xFF) << 8) | (payload[1] & 0xFF);
            if (!isValidCloseCode(code)) {
                throw new ProtocolError(1002, "invalid close code " + code);
            }
            reason = utf8(payload, 2, payload.length - 2);
        }
        serverCloseCode = code;
        serverCloseReason = reason;
        // answer the close unless this side closed first
        sendCloseQuietly(code == 1005 ? -1 : code, null);
    }

    /** Close codes a peer may send (RFC 6455 7.4, IANA registry). */
    static boolean isValidCloseCode(int code) {
        return (code >= 1000 && code <= 1003) || (code >= 1007 && code <= 1014) || (code >= 3000 && code <= 4999);
    }

    private void deliver(int op, byte[] message) throws IOException {
        if (silenced) {
            return;
        }
        if (op == OP_TEXT) {
            String text = utf8(message, 0, message.length);
            try {
                listener.onText(text);
            } catch (RuntimeException e) {
                YLog.w("WebSocket listener failed on a text message", e);
            }
        } else {
            try {
                listener.onBinary(message);
            } catch (RuntimeException e) {
                YLog.w("WebSocket listener failed on a binary message", e);
            }
        }
    }

    /** Strict UTF-8; invalid text closes the connection with 1007. */
    static String utf8(byte[] b, int off, int len) throws ProtocolError {
        try {
            return Codec.UTF_8.newDecoder()
                    .onMalformedInput(CodingErrorAction.REPORT)
                    .onUnmappableCharacter(CodingErrorAction.REPORT)
                    .decode(ByteBuffer.wrap(b, off, len))
                    .toString();
        } catch (CharacterCodingException e) {
            throw new ProtocolError(1007, "invalid UTF-8 in a text message");
        }
    }

    private int readByte() throws IOException {
        int b = in.read();
        if (b < 0) {
            throw new EOFException("connection closed without a close frame");
        }
        return b;
    }

    private byte[] readFully(int len) throws IOException {
        if (len == 0) {
            return EMPTY;
        }
        byte[] b = new byte[len];
        int n = 0;
        while (n < len) {
            int r = in.read(b, n, len - n);
            if (r < 0) {
                throw new EOFException("connection closed inside a frame");
            }
            n += r;
        }
        return b;
    }

    // ================================================================ writing

    @Override
    public void sendText(String text) throws IOException {
        byte[] b = text.getBytes(Codec.UTF_8);
        sendFrame(OP_TEXT, b, 0, b.length);
    }

    @Override
    public void sendBinary(byte[] data, int offset, int length) throws IOException {
        if (offset < 0 || length < 0 || offset + length > data.length) {
            throw new IllegalArgumentException("bad range");
        }
        sendFrame(OP_BINARY, data, offset, length);
    }

    @Override
    public void ping() throws IOException {
        sendFrame(OP_PING, EMPTY, 0, 0);
    }

    @Override
    public void setMaxMessageBytes(int bytes) {
        maxMessageBytes = bytes > 0 ? bytes : DEFAULT_MAX_MESSAGE_BYTES;
    }

    private void sendFrame(int op, byte[] data, int off, int len) throws IOException {
        synchronized (writeLock) {
            if (closeSent) {
                throw new IOException("the WebSocket is closing");
            }
            out.write(encodeFrame(op, data, off, len, randomBytes(4)));
            out.flush();
        }
    }

    /** Sends a close frame once; {@code code} -1 sends an empty close frame. Errors are ignored. */
    private void sendCloseQuietly(int code, String reason) {
        synchronized (writeLock) {
            if (closeSent) {
                return;
            }
            closeSent = true;
            try {
                byte[] payload = closePayload(code, reason);
                out.write(encodeFrame(OP_CLOSE, payload, 0, payload.length, randomBytes(4)));
                out.flush();
            } catch (IOException e) {
                YLog.d("WebSocket close frame not sent: " + e);
            }
        }
    }

    static byte[] closePayload(int code, String reason) {
        if (code < 0) {
            return EMPTY;
        }
        byte[] r = reason == null ? EMPTY : reason.getBytes(Codec.UTF_8);
        int n = Math.min(r.length, 123);
        while (n > 0 && n < r.length && (r[n] & 0xC0) == 0x80) {
            n--; // never cut a UTF-8 sequence
        }
        byte[] p = new byte[2 + n];
        p[0] = (byte) (code >> 8);
        p[1] = (byte) code;
        System.arraycopy(r, 0, p, 2, n);
        return p;
    }

    /** One final (FIN) client frame: masked payload with the 7, 16 or 64 bit length form. */
    static byte[] encodeFrame(int op, byte[] data, int off, int len, byte[] mask) {
        int header = len < 126 ? 2 : (len <= 0xFFFF ? 4 : 10);
        byte[] f = new byte[header + 4 + len];
        f[0] = (byte) (0x80 | op);
        if (len < 126) {
            f[1] = (byte) (0x80 | len);
        } else if (len <= 0xFFFF) {
            f[1] = (byte) (0x80 | 126);
            f[2] = (byte) (len >>> 8);
            f[3] = (byte) len;
        } else {
            f[1] = (byte) (0x80 | 127);
            long l = len;
            for (int i = 0; i < 8; i++) {
                f[2 + i] = (byte) (l >>> (56 - 8 * i));
            }
        }
        System.arraycopy(mask, 0, f, header, 4);
        for (int i = 0; i < len; i++) {
            f[header + 4 + i] = (byte) (data[off + i] ^ mask[i & 3]);
        }
        return f;
    }

    static byte[] randomBytes(int n) {
        byte[] b = new byte[n];
        RANDOM.nextBytes(b);
        return b;
    }

    // ================================================================ closing

    /**
     * Sends a close frame, waits up to {@link #CLOSE_WAIT_MS} for the server's close frame, then
     * closes the socket. No callbacks afterwards. Blocking: call it off the main thread.
     */
    @Override
    public void close(int code, String reason) {
        silenced = true;
        sendCloseQuietly(code, reason);
        if (Thread.currentThread() != reader) {
            try {
                readerDone.await(CLOSE_WAIT_MS, TimeUnit.MILLISECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        closeQuietly(socket);
        closeQuietly(raw);
    }

    /** Closes the TCP socket at once (no close frame); a blocked read or write fails. No callbacks afterwards. */
    @Override
    public void abort() {
        silenced = true;
        closeQuietly(raw);
        closeQuietly(socket);
    }

    /** True once the reader thread has ended. */
    boolean isReaderDone() {
        return readerDone.getCount() == 0;
    }

    private static void closeQuietly(Closeable c) {
        if (c != null) {
            try {
                c.close();
            } catch (IOException ignored) {
                // closing anyway
            } catch (RuntimeException ignored) {
                // some socket implementations throw on close after a failure
            }
        }
    }
}