All Downloads are FREE. Search and download functionalities are using the official Maven repository.

su.litvak.chromecast.api.v2.Channel Maven / Gradle / Ivy

There is a newer version: 0.11.3
Show newest version
/*
 * Copyright 2014 Vitaly Litvak ([email protected])
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package su.litvak.chromecast.api.v2;

import com.google.protobuf.InvalidProtocolBufferException;
import org.codehaus.jackson.JsonNode;
import org.codehaus.jackson.JsonProcessingException;
import org.codehaus.jackson.annotate.JsonSubTypes;
import org.codehaus.jackson.map.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicLong;

import static su.litvak.chromecast.api.v2.Util.fromArray;
import static su.litvak.chromecast.api.v2.Util.toArray;

/**
 * Internal class for low-level communication with ChromeCast device.
 * Should never be used directly, use {@link su.litvak.chromecast.api.v2.ChromeCast} methods instead
 */
class Channel implements Closeable {
    private static final Logger LOG = LoggerFactory.getLogger(Channel.class);
    /**
     * Period for sending ping requests (in ms)
     */
    private static final long PING_PERIOD = 30 * 1000;
    /**
     * Default value of much time to wait until request is processed
     */
    private static final long DEFAULT_REQUEST_TIMEOUT = 30 * 1000;

    private final static String DEFAULT_RECEIVER_ID = "receiver-0";

    private final EventListenerHolder eventListener;

    private static final JsonSubTypes.Type[] STANDARD_RESPONSE_TYPES =
            StandardResponse.class.getAnnotation(JsonSubTypes.class).value();

    /**
     * Single socket instance for transfers
     */
    private Socket socket;
    /**
     * Address of ChromeCast
     */
    private final InetSocketAddress address;
    /**
     * Name of sender used in this channel
     */
    private final String name;
    /**
     * Timer for PING requests
     */
    private Timer pingTimer;
    /**
     * Thread for processing incoming requests
     */
    private ReadThread reader;
    /**
     * Counter for producing request numbers
     */
    private AtomicLong requestCounter = new AtomicLong(1);
    /**
     * Processors of requests by their identifiers
     */
    private final Map> requests = new ConcurrentHashMap>();
    /**
     * Single mapper object for marshalling JSON
     */
    private final ObjectMapper jsonMapper = JacksonHelper.createJSONMapper();
    /**
     * Destination ids of sessions opened within this channel
     */
    private Set sessions = new HashSet();
    /**
     * Indicates that this channel was closed (explicitly, by remote host or for some connectivity issue)
     */
    private volatile boolean closed = true;
    private final Object closedSync = new Object();
    /**
     * How much time to wait until request is processed
     */
    private volatile long requestTimeout = DEFAULT_REQUEST_TIMEOUT;

    private class PingThread extends TimerTask {
        @Override
        public void run() {
            try {
                write("urn:x-cast:com.google.cast.tp.heartbeat", StandardMessage.ping(), DEFAULT_RECEIVER_ID);
            } catch (IOException ioex) {
                LOG.warn("Error while sending 'PING'", ioex);
            }
        }
    }

    private class ReadThread extends Thread {
        volatile boolean stop;

        @Override
        public void run() {
            while (!stop) {
                JsonNode parsed = null;
                String jsonMSG = null;
                CastChannel.CastMessage message = null;

                try {
                    message = read();
                    if (message.getPayloadType() == CastChannel.CastMessage.PayloadType.STRING) {
                        LOG.debug(" <-- {}",  message.getPayloadUtf8());
                        jsonMSG = message.getPayloadUtf8().replaceFirst("\"type\"", "\"responseType\"");
                        if (jsonMSG == null || jsonMSG.isEmpty()) {
                            LOG.warn(" <-- Received empty message. Ignore.");
                            continue;
                        }

                        // Determine whether the message belongs to cast protocol or is a custom
                        // message from the receiver app
                        parsed = jsonMapper.readTree(jsonMSG);
                    } else {
                        LOG.warn("Received unexpected {} message", message.getPayloadType());
                    }
                } catch (InvalidProtocolBufferException ipbe) {
                    LOG.warn("Error while processing protobuf", ipbe);
                } catch (JsonProcessingException jpe) {
                    LOG.warn("Error while processing json", jpe);
                } catch (IOException ioex) {
                    if (stop) {
                        LOG.warn("Got IOException while reading due to stream being closed (stop=true)");
                        continue;
                    }
                    LOG.warn("Error while reading", ioex);
                    String temp;
                    if (message != null &&  message.getPayloadUtf8() != null) {
                        temp = message.getPayloadUtf8();
                    } else {
                        temp = " null payload in message ";
                    }
                    LOG.warn(" <-- {}", temp);
                    try {
                        close();
                    } catch (IOException e) {
                        LOG.warn("Error while closing channel", ioex);
                    }
                } catch (Exception e) {
                    LOG.warn("Unknown error while reading", e);
                    continue;
                }

                try {
                    if (message == null) {
                        continue;
                    }

                    if (isAppEvent(parsed)) {
                        // This handles when parsed == null.
                        AppEvent event = new AppEvent(message.getNamespace(), message.getPayloadUtf8());
                        notifyListenersAppEvent(event);
                    } else {
                        if (parsed.has("requestId")) {
                            Long requestId = parsed.get("requestId").asLong();
                            final ResultProcessor rp = requests.remove(requestId);
                            if (rp != null) {
                                rp.put(jsonMSG);
                            } else {
                                notifyListenersOfSpontaneousEvent(parsed);
                            }
                        } else if (parsed.has("responseType") && parsed.get("responseType").asText().equals("MEDIA_STATUS")) {
                            notifyListenersOfSpontaneousEvent(parsed);
                        } else if (parsed.has("responseType") && parsed.get("responseType").asText().equals("PING")) {
                            write("urn:x-cast:com.google.cast.tp.heartbeat", StandardMessage.pong(), DEFAULT_RECEIVER_ID);
                        } else if (parsed.has("responseType") && parsed.get("responseType").asText().equals("CLOSE")) {
                            notifyListenersOfSpontaneousEvent(parsed);
                        }
                    }
                } catch (Exception e) {
                    LOG.warn("Error while handling", e);
                }
            }
        }

        private boolean isAppEvent(JsonNode parsed) {
            if (parsed != null && parsed.has("responseType")) {
                String type = parsed.get("responseType").asText();
                for (JsonSubTypes.Type t : STANDARD_RESPONSE_TYPES) {
                    if (t.name().equals(type)) {
                        return false;
                    }
                }
            }
            return parsed == null || !parsed.has("requestId");
        }
    }

    private class ResultProcessor {
        final Class responseClass;
        T result;

        private ResultProcessor(Class responseClass) {
            if (responseClass == null) {
                throw new NullPointerException();
            }
            this.responseClass = responseClass;
        }

        public void put(String jsonMSG) throws IOException {
            synchronized (this) {
                this.result = jsonMapper.readValue(jsonMSG, responseClass);
                this.notify();
            }
        }

        public T get() throws InterruptedException, TimeoutException {
            synchronized (this) {
                if (result != null) {
                    return result;
                }
                this.wait(requestTimeout);
                if (result == null) {
                    throw new TimeoutException();
                }
                return result;
            }
        }
    }

    Channel(String host, EventListenerHolder eventListener) {
        this(host, 8009, eventListener);
    }

    Channel(String host, int port, EventListenerHolder eventListener) {
        this.address = new InetSocketAddress(host, port);
        this.name = "sender-" + new RandomString(10).nextString();
        this.eventListener = eventListener;
    }

    /**
     * Open the channel.
     *
     * 

This function must be called before any other usage.

* * @throws IOException * @throws GeneralSecurityException */ public void open() throws IOException, GeneralSecurityException { if (!closed) { throw new ChromeCastException("Channel already opened."); } connect(); } /** * Establish connection to the ChromeCast device */ private void connect() throws IOException, GeneralSecurityException { synchronized (closedSync) { if (socket == null || socket.isClosed()) { SSLContext sc = SSLContext.getInstance("SSL"); sc.init(null, new TrustManager[] { new X509TrustAllManager() }, new SecureRandom()); socket = sc.getSocketFactory().createSocket(); socket.connect(address); } /** * Authenticate */ CastChannel.DeviceAuthMessage authMessage = CastChannel.DeviceAuthMessage.newBuilder() .setChallenge(CastChannel.AuthChallenge.newBuilder().build()) .build(); CastChannel.CastMessage msg = CastChannel.CastMessage.newBuilder() .setDestinationId(DEFAULT_RECEIVER_ID) .setNamespace("urn:x-cast:com.google.cast.tp.deviceauth") .setPayloadType(CastChannel.CastMessage.PayloadType.BINARY) .setProtocolVersion(CastChannel.CastMessage.ProtocolVersion.CASTV2_1_0) .setSourceId(name) .setPayloadBinary(authMessage.toByteString()) .build(); write(msg); CastChannel.CastMessage response = read(); CastChannel.DeviceAuthMessage authResponse = CastChannel.DeviceAuthMessage.parseFrom(response.getPayloadBinary()); if (authResponse.hasError()) { throw new ChromeCastException("Authentication failed: " + authResponse.getError().getErrorType().toString()); } /** * Send 'PING' message */ PingThread pingThread = new PingThread(); pingThread.run(); /** * Send 'CONNECT' message to start session */ write("urn:x-cast:com.google.cast.tp.connection", StandardMessage.connect(), DEFAULT_RECEIVER_ID); /** * Start ping/pong and reader thread */ pingTimer = new Timer(name + " PING"); pingTimer.schedule(pingThread, 1000, PING_PERIOD); reader = new ReadThread(); reader.start(); if (closed) { closed = false; notifyListenerOfConnectionEvent(true); } } } @SuppressWarnings("unchecked") private T sendStandard(String namespace, StandardRequest message, String destinationId) throws IOException { return send(namespace, message, destinationId, (Class) StandardResponse.class); } private T send(String namespace, Request message, String destinationId, Class responseClass) throws IOException { /** * Try to reconnect */ if (isClosed()) { try { connect(); } catch (GeneralSecurityException gse) { throw new ChromeCastException("Unexpected security exception", gse); } } Long requestId = requestCounter.getAndIncrement(); message.setRequestId(requestId); if (!requestId.equals(message.getRequestId())) { throw new IllegalStateException("Request Id getter/setter contract violation"); } if (responseClass == null) { write(namespace, message, destinationId); return null; } ResultProcessor rp = new ResultProcessor(responseClass); requests.put(requestId, rp); write(namespace, message, destinationId); try { T response = rp.get(); if (response instanceof StandardResponse.Invalid) { StandardResponse.Invalid invalid = (StandardResponse.Invalid) response; throw new ChromeCastException("Invalid request: " + invalid.reason); } else if (response instanceof StandardResponse.LoadFailed) { throw new ChromeCastException("Unable to load media"); } else if (response instanceof StandardResponse.LaunchError) { StandardResponse.LaunchError launchError = (StandardResponse.LaunchError) response; throw new ChromeCastException("Application launch error: " + launchError.reason); } return response; } catch (InterruptedException e) { throw new ChromeCastException("Interrupted while waiting for response", e); } catch (TimeoutException e) { throw new ChromeCastException("Waiting for response timed out", e); } finally { requests.remove(requestId); } } private void write(String namespace, Message message, String destinationId) throws IOException { write(namespace, jsonMapper.writeValueAsString(message), destinationId); } private void write(String namespace, String message, String destinationId) throws IOException { LOG.debug(" --> {}", message); CastChannel.CastMessage msg = CastChannel.CastMessage.newBuilder() .setProtocolVersion(CastChannel.CastMessage.ProtocolVersion.CASTV2_1_0) .setSourceId(name) .setDestinationId(destinationId) .setNamespace(namespace) .setPayloadType(CastChannel.CastMessage.PayloadType.STRING) .setPayloadUtf8(message) .build(); write(msg); } private void write(CastChannel.CastMessage message) throws IOException { socket.getOutputStream().write(toArray(message.getSerializedSize())); message.writeTo(socket.getOutputStream()); } private CastChannel.CastMessage read() throws IOException { InputStream is = socket.getInputStream(); byte[] buf = new byte[4]; int read = 0; while (read < buf.length) { int nextByte = is.read(); if (nextByte == -1) { throw new ChromeCastException("Remote socket closed"); } buf[read++] = (byte) nextByte; } int size = fromArray(buf); buf = new byte[size]; read = 0; while (read < size) { int nowRead = is.read(buf, read, buf.length - read); if (nowRead == -1) { throw new ChromeCastException("Remote socket closed"); } read += nowRead; } return CastChannel.CastMessage.parseFrom(buf); } private void notifyListenerOfConnectionEvent(final boolean connected) { if (this.eventListener != null) { this.eventListener.deliverConnectionEvent(connected); } } private void notifyListenersOfSpontaneousEvent(JsonNode json) throws IOException { if (this.eventListener != null) { this.eventListener.deliverEvent(json); } } private void notifyListenersAppEvent(AppEvent event) throws IOException { if (this.eventListener != null) { this.eventListener.deliverAppEvent(event); } } public Status getStatus() throws IOException { StandardResponse.Status status = sendStandard("urn:x-cast:com.google.cast.receiver", StandardRequest.status(), "receiver-0"); return status == null ? null : status.status; } public boolean isAppAvailable(String appId) throws IOException { StandardResponse.AppAvailability availability = sendStandard("urn:x-cast:com.google.cast.receiver", StandardRequest.appAvailability(appId), "receiver-0"); return availability != null && "APP_AVAILABLE".equals(availability.availability.get(appId)); } public Status launch(String appId) throws IOException { StandardResponse.Status status = sendStandard("urn:x-cast:com.google.cast.receiver", StandardRequest.launch(appId), DEFAULT_RECEIVER_ID); return status == null ? null : status.status; } public Status stop(String sessionId) throws IOException { StandardResponse.Status status = sendStandard("urn:x-cast:com.google.cast.receiver", StandardRequest.stop(sessionId), DEFAULT_RECEIVER_ID); return status == null ? null : status.status; } private void startSession(String destinationId) throws IOException { if (!sessions.contains(destinationId)) { write("urn:x-cast:com.google.cast.tp.connection", StandardMessage.connect(), destinationId); sessions.add(destinationId); } } public MediaStatus load(String destinationId, String sessionId, Media media, boolean autoplay, double currentTime, Map customData) throws IOException { startSession(destinationId); StandardResponse.MediaStatus status = sendStandard("urn:x-cast:com.google.cast.media", StandardRequest.load(sessionId, media, autoplay, currentTime, customData), destinationId); return status == null || status.statuses.length == 0 ? null : status.statuses[0]; } public MediaStatus play(String destinationId, String sessionId, long mediaSessionId) throws IOException { startSession(destinationId); StandardResponse.MediaStatus status = sendStandard("urn:x-cast:com.google.cast.media", StandardRequest.play(sessionId, mediaSessionId), destinationId); return status == null || status.statuses.length == 0 ? null : status.statuses[0]; } public MediaStatus pause(String destinationId, String sessionId, long mediaSessionId) throws IOException { startSession(destinationId); StandardResponse.MediaStatus status = sendStandard("urn:x-cast:com.google.cast.media", StandardRequest.pause(sessionId, mediaSessionId), destinationId); return status == null || status.statuses.length == 0 ? null : status.statuses[0]; } public MediaStatus seek(String destinationId, String sessionId, long mediaSessionId, double currentTime) throws IOException { startSession(destinationId); StandardResponse.MediaStatus status = sendStandard("urn:x-cast:com.google.cast.media", StandardRequest.seek(sessionId, mediaSessionId, currentTime), destinationId); return status == null || status.statuses.length == 0 ? null : status.statuses[0]; } public Status setVolume(Volume volume) throws IOException { StandardResponse.Status status = sendStandard("urn:x-cast:com.google.cast.receiver", StandardRequest.setVolume(volume), DEFAULT_RECEIVER_ID); return status == null ? null : status.status; } public MediaStatus getMediaStatus(String destinationId) throws IOException { startSession(destinationId); StandardResponse.MediaStatus status = sendStandard("urn:x-cast:com.google.cast.media", StandardRequest.status(), destinationId); return status == null || status.statuses.length == 0 ? null : status.statuses[0]; } public T sendGenericRequest(String destinationId, String namespace, Request request, Class responseClass) throws IOException { startSession(destinationId); return send(namespace, request, destinationId, responseClass); } @Override public void close() throws IOException { synchronized (closedSync) { if (closed) { throw new ChromeCastException("Channel already closed."); } else { closed = true; notifyListenerOfConnectionEvent(false); if (pingTimer != null) { pingTimer.cancel(); } if (reader != null) { reader.stop = true; } if (socket != null) { socket.close(); } } } } public boolean isClosed() { return closed; } public void setRequestTimeout(long requestTimeout) { this.requestTimeout = requestTimeout; } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy