/* * Copyright (C) 2009 Google Inc. All rights reserved. * * 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 com.google.android.apps.tvremote; import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; import java.net.UnknownHostException; import java.security.GeneralSecurityException; import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; import java.util.Map; import java.util.Set; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; import android.app.Service; import android.content.Intent; import android.content.SharedPreferences; import android.os.AsyncTask; import android.os.Binder; import android.os.Handler; import android.os.IBinder; import android.os.Message; import android.util.Log; import com.google.android.apps.tvremote.protocol.AnymoteSender; import com.google.android.apps.tvremote.protocol.DummySender; import com.google.android.apps.tvremote.util.Debug; import com.google.android.apps.tvremote.util.LimitedLinkedHashMap; /** * The central point to connect to a remote box and send commands. * */ public final class CoreService extends Service implements ConnectionManager { private static final String LOG_TAG = "TvRemoteCoreService"; /** * Connection status enumeration. */ public enum ConnectionStatus { /** * Connection successful. */ OK, /** * Error while creating socket or establishing connection. */ ERROR_CREATE, /** * Error during SSL handshake. */ ERROR_HANDSHAKE } private ConnectionListener connectionListener; private Socket sendSocket; private RemoteDevice target; private LimitedLinkedHashMap<InetAddress, RemoteDevice> recentlyConnected; /** * Key store manager. */ private KeyStoreManager keyStoreManager; private Handler handler; private ConnectionTask connectionTask; private static final Map<State, Set<State>> ALLOWED_TRANSITION = allowedTransitions(); private enum State { IDLE, CONNECTING, CONNECTED, DISCONNECTING, DEVICE_FINDER, PAIRING } /** * Various tags used to store the service's configuration. */ private static final String SHARED_PREF_NAME = "CoreServicePrefs"; private static final String DEVICE_NAME_TAG = "DeviceName"; private static final String DEVICE_IP_TAG = "DeviceIp"; private static final String DEVICE_PORT_TAG = "DevicePort"; /** * Notable values for ports, ip addresses and target names. */ private static final int MIN_PORT = 0; private static final int MAX_PORT = 0xFFFF; private static final int INVALID_PORT = -1; private static final String INVALID_IP = "no#ip"; private static final String INVALID_TARGET = "no#target"; /** * Timeout when creating a socket. */ private static int SOCKET_CREATION_TIMEOUT_MS = 300; /** * Timeout when reconnecting. */ private static final int RECONNECTION_DELAY_MS = 1000; private static final int MAX_CONNECTION_ATTEMPTS = 3; /** * Sender that uses the Anymote protocol. */ private AnymoteSender anymoteSender; public CoreService() { target = null; sendSocket = null; } @Override public void onCreate() { super.onCreate(); handler = new Handler(new ConnectionRequestCallback()); recentlyConnected = new LimitedLinkedHashMap<InetAddress, RemoteDevice>( getResources().getInteger(R.integer.recently_connected_count)); keyStoreManager = new KeyStoreManager(this); loadConfig(); } @Override public void onDestroy() { storeConfig(); cleanupSocket(); if (keyStoreManager != null) { keyStoreManager.store(); } super.onDestroy(); } @Override public IBinder onBind(Intent intent) { return new LocalBinder(); } /** * Determines whether a port number is valid. * * @param port an integer representing the port number * @return {@code true} if the number falls within the range of valid ports */ private static boolean isPortValid(int port) { return port > MIN_PORT && port < MAX_PORT; } /** * Validates a connection configuration. * * @param name a string representing the name of the target * @param ip a string representing the ip of the target * @param port an integer representing the target's remote port * @return {@code true} if the configuration is valid */ private static boolean isConfigValid(String name, String ip, int port) { return !INVALID_TARGET.equals(name) && !INVALID_IP.equals(ip) && isPortValid(port); } private void cleanupSocket() { if (sendSocket == null) { return; } Log.i(LOG_TAG, "Closing connection to " + sendSocket.getInetAddress() + ":" + sendSocket.getPort()); if (anymoteSender != null) { anymoteSender.disconnect(); anymoteSender = null; } try { sendSocket.close(); } catch (IOException e) { Log.e(LOG_TAG, "failed to close socket"); } sendSocket = null; } /** * Stores the service's configuration to saved preferences. * * @return {@code true} if the config was saved */ private boolean storeConfig() { SharedPreferences pref = getSharedPreferences(SHARED_PREF_NAME, MODE_PRIVATE); SharedPreferences.Editor prefEdit = pref.edit(); prefEdit.clear(); if (target != null) { storeRemoteDevice(prefEdit, "", target); } int index = 0; for (RemoteDevice remoteDevice : recentlyConnected.values()) { storeRemoteDevice(prefEdit, "_" + index, remoteDevice); ++index; } if (target != null || index > 0) { prefEdit.commit(); return true; } return false; } private void storeRemoteDevice(SharedPreferences.Editor prefEdit, String suffix, RemoteDevice remoteDevice) { prefEdit.putString(DEVICE_NAME_TAG + suffix, remoteDevice.getName()); prefEdit.putString(DEVICE_IP_TAG + suffix, remoteDevice.getAddress().getHostAddress()); prefEdit.putInt(DEVICE_PORT_TAG + suffix, remoteDevice.getPort()); } /** * Loads an existing configuration, and builds the socket to the target. */ private void loadConfig() { SharedPreferences pref = getSharedPreferences(SHARED_PREF_NAME, MODE_PRIVATE); RemoteDevice restoredTarget = loadRemoteDevice(pref, ""); for (int i = 0; i < getResources() .getInteger(R.integer.recently_connected_count); ++i) { RemoteDevice remoteDevice = loadRemoteDevice(pref, "_" + i); if (remoteDevice != null) { recentlyConnected.put(remoteDevice.getAddress(), remoteDevice); } } if (restoredTarget != null) { setTarget(restoredTarget); } } private RemoteDevice loadRemoteDevice(SharedPreferences pref, String suffix) { String name = pref.getString(DEVICE_NAME_TAG + suffix, INVALID_TARGET); String ip = pref.getString(DEVICE_IP_TAG + suffix, INVALID_IP); int port = pref.getInt(DEVICE_PORT_TAG + suffix, INVALID_PORT); if (!isConfigValid(name, ip, port)) { return null; } InetAddress address; try { address = InetAddress.getByName(ip); } catch (UnknownHostException e) { return null; } return new RemoteDevice(name, address, port); } /** * Enables in-process access to this service. */ final class LocalBinder extends Binder { CoreService getService() { return CoreService.this; } } public KeyStoreManager getKeyStoreManager() { return keyStoreManager; } private void addRecentlyConnected(RemoteDevice remoteDevice) { recentlyConnected.remove(remoteDevice.getAddress()); recentlyConnected.put(remoteDevice.getAddress(), remoteDevice); storeConfig(); } // CONNECTION MANAGER private enum Request { CONNECT, CONNECTED, SET_TARGET, DISCONNECT, CONNECTION_ERROR, SET_KEEP_CONNECTED, REQUEST_PAIRING, PAIRING_FINISHED, REQUEST_DEVICE_FINDER, DEVICE_FINDER_FINISHED, } public void notifyConnectionFailed() { sendMessage(Request.CONNECTION_ERROR, null); } public void connect(ConnectionListener listener) { sendMessage(Request.CONNECT, listener); } public void connected(ConnectionResult result) { sendMessage(Request.CONNECTED, result); } public void disconnect(ConnectionListener listener) { sendMessage(Request.DISCONNECT, listener); } public void setKeepConnected(boolean keepConnected) { sendMessage(Request.SET_KEEP_CONNECTED, Boolean.valueOf(keepConnected)); } public void setTarget(RemoteDevice remoteDevice) { sendMessage(Request.SET_TARGET, remoteDevice); } public RemoteDevice getTarget() { return target; } public ArrayList<RemoteDevice> getRecentlyConnected() { ArrayList<RemoteDevice> devices = new ArrayList<RemoteDevice>( recentlyConnected.values()); Collections.reverse(devices); return devices; } public void pairingFinished() { sendMessage(Request.PAIRING_FINISHED, null); } public void deviceFinderFinished() { sendMessage(Request.DEVICE_FINDER_FINISHED, null); } public void requestDeviceFinder() { sendMessage(Request.REQUEST_DEVICE_FINDER, null); } private void requestPairing() { sendMessage(Request.REQUEST_PAIRING, null); } private void sendMessage(Request request, Object obj) { Message msg = handler.obtainMessage(request.ordinal()); msg.obj = obj; handler.dispatchMessage(msg); } private class ConnectionRequestCallback implements Handler.Callback { private int keepConnectedRefcount; private State currentState = State.IDLE; private boolean pendingNotification; private boolean changeState(State newState) { return changeState(newState, null); } private boolean changeState(State newState, Runnable callback) { if (isTransitionLegal(currentState, newState)) { if (Debug.isDebugConnection()) { Log.d(LOG_TAG, "Changing state: " + currentState + " -> " + newState); } currentState = newState; if (callback != null) { callback.run(); } sendNotification(); return true; } if (Debug.isDebugConnection()) { Log.d(LOG_TAG, "Illegal transition: " + currentState + " -> " + newState); } return false; } public boolean handleMessage(Message msg) { Request request = Request.values()[msg.what]; Log.v(LOG_TAG, "handleMessage:" + request + " (" + msg.obj + ")"); switch (request) { case CONNECT: handleConnect((ConnectionListener) msg.obj); return true; case CONNECTED: connectionTask = null; handleConnected((ConnectionResult) msg.obj); return true; case DISCONNECT: handleDisconnect((ConnectionListener) msg.obj); return true; case SET_TARGET: handleSetTarget((RemoteDevice) msg.obj); return true; case CONNECTION_ERROR: handleConnectionError(); return true; case SET_KEEP_CONNECTED: handleSetKeepConnected((Boolean) msg.obj); return true; case REQUEST_DEVICE_FINDER: handleRequestDeviceFinder(); return true; case REQUEST_PAIRING: handleRequestPairing(); return true; case PAIRING_FINISHED: changeState(State.IDLE); return true; case DEVICE_FINDER_FINISHED: changeState(State.IDLE); return true; } return false; } private boolean isConnected() { return Debug.isDebugConnectionLess() || sendSocket != null; } private boolean isConnecting() { return State.CONNECTING.equals(currentState); } private void handleConnectionError() { if (changeState(State.DISCONNECTING)) { cleanupSocket(); } if (changeState(State.CONNECTING)) { connect(); } } private void handleConnect(ConnectionListener listener) { handleSetKeepConnected(true); if (listener != connectionListener) { connectionListener = listener; if (pendingNotification) { sendNotification(); } else if (isConnecting() || isConnected()) { sendNotification(); } } if (target != null && changeState(State.CONNECTING)) { connect(); } else if (target == null) { changeState(State.DEVICE_FINDER); } } private void handleRequestDeviceFinder() { stopConnectionTask(); disconnect(true); changeState(State.DEVICE_FINDER); } private void handleRequestPairing() { stopConnectionTask(); changeState(State.PAIRING); } private void handleConnected(final ConnectionResult result) { stopConnectionTask(); if (sendSocket != null) { throw new IllegalStateException(); } changeState(State.CONNECTED, new Runnable() { public void run() { addRecentlyConnected(target); anymoteSender = result.sender; sendSocket = result.socket; } }); } private void handleDisconnect(ConnectionListener listener) { handleSetKeepConnected(false); if (listener == connectionListener) { connectionListener = null; } } private void handleSetKeepConnected(boolean keepConnected) { keepConnectedRefcount += keepConnected ? 1 : -1; if (Debug.isDebugConnection()) { Log.d(LOG_TAG, "KeepConnectedRefcount: " + keepConnectedRefcount); } if (keepConnectedRefcount < 0) { throw new IllegalStateException("KeepConnectedRefCount < 0"); } if (connectionListener == null) { disconnect(false); } } private void handleSetTarget(RemoteDevice remoteDevice) { disconnect(true); target = remoteDevice; if (target != null && changeState(State.CONNECTING)) { connect(); } } private void disconnect(boolean unconditionally) { if (unconditionally || keepConnectedRefcount == 0) { if (isConnected()) { changeState(State.DISCONNECTING); cleanupSocket(); changeState(State.IDLE); } else if (isConnecting()) { changeState(State.DISCONNECTING); stopConnectionTask(); changeState(State.IDLE); } } } private void connect() { if (Debug.isDebugConnection()) { Log.d(LOG_TAG, "Connecting to: " + target); } if (sendSocket != null) { throw new IllegalStateException("Already connected"); } if (target == null) { changeState(State.DEVICE_FINDER); return; } startConnectionTask(target); } private void sendNotification() { if (connectionListener == null) { pendingNotification = true; if (Debug.isDebugConnection()) { Log.d(LOG_TAG, "Pending notification: " + currentState); } return; } pendingNotification = false; if (Debug.isDebugConnection()) { Log.d(LOG_TAG, "Sending notification: " + currentState + " to " + connectionListener); } switch (currentState) { case IDLE: break; case CONNECTING: connectionListener.onConnecting(); break; case CONNECTED: connectionListener.onConnectionSuccessful( Debug.isDebugConnectionLess() ? new DummySender() : anymoteSender); break; case DISCONNECTING: connectionListener.onDisconnected(); break; case DEVICE_FINDER: connectionListener.onShowDeviceFinder(); break; case PAIRING: if (target != null) { connectionListener.onNeedsPairing(target); } else { connectionListener.onShowDeviceFinder(); } break; default: throw new IllegalStateException("Unsupported state: " + currentState); } } } private void startConnectionTask(RemoteDevice remoteDevice) { stopConnectionTask(); connectionTask = new ConnectionTask(this); connectionTask.execute(remoteDevice); } private void stopConnectionTask() { if (connectionTask != null) { connectionTask.cancel(true); connectionTask = null; } } private static class ConnectionResult { final ConnectionStatus status; final AnymoteSender sender; final Socket socket; private ConnectionResult(ConnectionStatus status, AnymoteSender sender, Socket socket) { this.status = status; this.sender = sender; this.socket = socket; } } private static class ConnectionTask extends AsyncTask<RemoteDevice, Void, ConnectionResult> { private final CoreService coreService; private AnymoteSender sender; private Socket socket; private ConnectionTask(CoreService coreService) { this.coreService = coreService; } @Override protected ConnectionResult doInBackground(RemoteDevice... params) { if (params.length != 1) { throw new IllegalStateException("Expected exactly one remote device"); } for (int i = 0; i <= MAX_CONNECTION_ATTEMPTS; ++i) { try { Thread.sleep(RECONNECTION_DELAY_MS * i); } catch (InterruptedException e) { return null; } sender = null; socket = null; if (isCancelled()) { return null; } ConnectionStatus status = buildSocket(params[0]); if (isCancelled()) { return null; } switch (status) { case OK: return new ConnectionResult(status, sender, socket); case ERROR_HANDSHAKE: return new ConnectionResult(status, null, null); case ERROR_CREATE: // try to reconnect break; default: throw new IllegalStateException("Unsupported status: " + status); } } return new ConnectionResult(ConnectionStatus.ERROR_CREATE, null, null); } private ConnectionStatus buildSocket(RemoteDevice target) { if (target == null) { throw new IllegalStateException(); } // Set up the new connection. try { socket = getSslSocket(target); } catch (SSLException e) { Log.e(LOG_TAG, "(SSL) Could not create socket to " + target, e); return ConnectionStatus.ERROR_HANDSHAKE; } catch (GeneralSecurityException e) { Log.e(LOG_TAG, "(GSE) Could not create socket to " + target, e); return ConnectionStatus.ERROR_HANDSHAKE; } catch (IOException e) { // Hack for Froyo which throws IOException for SSL handshake problem: if (e.getMessage().startsWith("SSL handshake")) { return ConnectionStatus.ERROR_HANDSHAKE; } Log.e(LOG_TAG, "(IOE) Could not create socket to " + target, e); return ConnectionStatus.ERROR_CREATE; } Log.i(LOG_TAG, "Connected to " + target); if (isCancelled()) { return ConnectionStatus.ERROR_CREATE; } sender = new AnymoteSender(coreService); if (!sender.setSocket(socket)) { Log.e(LOG_TAG, "Initial message failed"); sender.disconnect(); try { socket.close(); } catch (IOException e) { Log.e(LOG_TAG, "failed to close socket"); } return ConnectionStatus.ERROR_CREATE; } // Connection successful - we need to reset connection attempts counter, // so next time the connection will drop we will try reconnecting. return ConnectionStatus.OK; } /** * Generates an SSL-enabled socket. * * @return the new socket * @throws GeneralSecurityException on error building the socket * @throws IOException on error loading the KeyStore */ private SSLSocket getSslSocket(RemoteDevice target) throws GeneralSecurityException, IOException { // Build a new key store based on the key store manager. KeyManager[] keyManagers = coreService.getKeyStoreManager() .getKeyManagers(); TrustManager[] trustManagers = coreService.getKeyStoreManager() .getTrustManagers(); if (keyManagers.length == 0) { throw new IllegalStateException("No key managers"); } // Create a new SSLContext, using the new KeyManagers and TrustManagers // as the sources of keys and trust decisions, respectively. SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(keyManagers, trustManagers, null); // Finally, build a new SSLSocketFactory from the SSLContext, and // then generate a new SSLSocket from it. SSLSocketFactory factory = sslContext.getSocketFactory(); SSLSocket sock = (SSLSocket) factory.createSocket(); sock.setNeedClientAuth(true); sock.setUseClientMode(true); sock.setKeepAlive(true); sock.setTcpNoDelay(true); InetSocketAddress fullAddr = new InetSocketAddress(target.getAddress(), target.getPort()); sock.connect(fullAddr, SOCKET_CREATION_TIMEOUT_MS); sock.startHandshake(); return sock; } // Notifications @Override protected void onCancelled() { super.onCancelled(); } @Override protected void onPostExecute(ConnectionResult result) { super.onPostExecute(result); switch (result.status) { case OK: coreService.connected(result); break; case ERROR_CREATE: coreService.requestDeviceFinder(); break; case ERROR_HANDSHAKE: coreService.requestPairing(); break; } } } // State transition management private static Map<State, Set<State>> allowedTransitions() { Map<State, Set<State>> allowedTransitions = new HashMap<State, Set<State>>(); allowedTransitions.put(State.IDLE, EnumSet.of(State.IDLE, State.CONNECTING, State.DEVICE_FINDER)); allowedTransitions.put(State.CONNECTING, EnumSet.of(State.CONNECTED, State.DEVICE_FINDER, State.PAIRING, State.DISCONNECTING)); allowedTransitions.put(State.CONNECTED, EnumSet.of(State.DISCONNECTING)); allowedTransitions.put(State.DEVICE_FINDER, EnumSet.of(State.IDLE)); allowedTransitions.put(State.PAIRING, EnumSet.of(State.IDLE)); allowedTransitions.put(State.DISCONNECTING, EnumSet.of(State.IDLE, State.CONNECTING)); for (State state : State.values()) { if (!allowedTransitions.containsKey(state)) { throw new IllegalStateException("Incomplete transition map"); } } return allowedTransitions; } private static boolean isTransitionLegal(State from, State to) { return ALLOWED_TRANSITION.get(from).contains(to); } }