/*
* Copyright (C) 2005-2015 Team XBMC
* http://xbmc.org
*
* This Program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This Program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with XBMC Remote; see the file license. If not, write to
* the Free Software Foundation, 675 Mass Ave, Cambridge, MA 02139, USA.
* http://www.gnu.org/copyleft/gpl.html
*
*/
package org.xbmc.android.jsonrpc.service;
import java.io.BufferedWriter;
import java.io.EOFException;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Timer;
import java.util.TimerTask;
import org.codehaus.jackson.JsonFactory;
import org.codehaus.jackson.JsonNode;
import org.codehaus.jackson.JsonParseException;
import org.codehaus.jackson.JsonParser;
import org.codehaus.jackson.map.ObjectMapper;
import org.codehaus.jackson.node.ObjectNode;
import org.xbmc.android.jsonrpc.api.AbstractCall;
import org.xbmc.android.jsonrpc.io.ApiException;
import org.xbmc.android.jsonrpc.io.ConnectionManager;
import org.xbmc.android.jsonrpc.io.JsonHandler;
import org.xbmc.android.jsonrpc.notification.AbstractEvent;
import android.app.IntentService;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
import android.util.Log;
/**
* Service which keeps a steady TCP connection to XBMC's JSON-RPC API via TCP
* socket (as opposed to HTTP messages).
* <p/>
* It serves as listener for notification, but is also used for posting normal
* API requests.
* <p/>
* Generally speaking, the service will shut down and terminate the TCP
* connection as soon as there are no more connected clients. However, clients
* may want to query several consecutive requests without having the service
* stop and restart between every request. That is the reason why there is a
* "cooldown" period, in which the service will just wait for new clients to
* arrive before shutting down.
* <p/>
* About message exchange, see {@link ConnectionManager}.
*
* @author freezy <freezy@xbmc.org>
*/
public class ConnectionService extends IntentService {
public final static String TAG = ConnectionService.class.getSimpleName();
private static final int SOCKET_TIMEOUT = 5000;
public static final String EXTRA_ADDRESS = "org.xbmc.android.jsonprc.extra.ADDRESS";
public static final String EXTRA_TCPPORT = "org.xbmc.android.jsonprc.extra.TCPPORT";
public static final String EXTRA_HTTPPORT = "org.xbmc.android.jsonprc.extra.HTTPPORT";
public static final String EXTRA_STATUS = "org.xbmc.android.jsonprc.extra.STATUS";
public static final String EXTRA_APICALL = "org.xbmc.android.jsonprc.extra.APICALL";
public static final String EXTRA_NOTIFICATION = "org.xbmc.android.jsonprc.extra.NOTIFICATION";
public static final String EXTRA_HANDLER = "org.xbmc.android.jsonprc.extra.HANDLER";
public static final String EXTRA_CALLID = "org.xbmc.android.jsonprc.extra.CALLID";
public static final int MSG_REGISTER_CLIENT = 0x01;
public static final int MSG_UNREGISTER_CLIENT = 0x02;
public static final int MSG_CONNECTING = 0x03;
public static final int MSG_CONNECTED = 0x04;
public static final int MSG_RECEIVE_NOTIFICATION = 0x05;
public static final int MSG_RECEIVE_APICALL = 0x06;
public static final int MSG_RECEIVE_HANDLED_APICALL = 0x07;
public static final int MSG_SEND_APICALL = 0x08;
public static final int MSG_SEND_HANDLED_APICALL = 0x09;
public static final int MSG_ERROR = 0x0a;
public static final int RESULT_SUCCESS = 0x01;
/**
* Time in milliseconds we wait for new requests until we shut down the
* service (and connection).
*/
private static final long COOLDOWN = 10000;
/**
* Target we publish for clients to send messages to IncomingHandler.
*/
private final Messenger mMessenger = new Messenger(new IncomingHandler());
/**
* Keeps track of all currently registered client. Normally, all clients
* are {@link ConnectionManager} instances.
*/
private final ArrayList<Messenger> mClients = new ArrayList<Messenger>();
/**
* API call results are only returned to the client requested it, so here are the relations.
*/
private final HashMap<String, Messenger> mClientMap = new HashMap<String, Messenger>();
/**
* If we have to send data before we're connected, store data until connection
*/
private final LinkedList<AbstractCall<?>> mPendingCalls = new LinkedList<AbstractCall<?>>();
/**
* All calls the service is currently dealing with. Key is the ID of the call.
*/
private final HashMap<String, AbstractCall<?>> mCalls = new HashMap<String, AbstractCall<?>>();
/**
* The handler we'll update with a status code as soon as we're done.
*/
private final HashMap<String, JsonHandler> mHandlers = new HashMap<String, JsonHandler>();
/**
* Reference to the socket, so we shut it down properly.
*/
private Socket mSocket = null;
/**
* Output writer so we can also write stuff to the socket.
*/
private BufferedWriter mOut = null;
/**
* Static reference to Jackson's object mapper.
*/
private final static ObjectMapper OM = new ObjectMapper();
/**
* When no more clients are connected, wait {@link #COOLDOWN} milliseconds
* and then shut down the service if no new clients connect.
*/
private Timer mCooldownTimer = null;
/**
* Host name or IP address of XBMC
*/
private String mAddress;
/**
* Port where the TCP service is running (default 9090).
*/
private int mPort;
/**
* Class constructor must be empty for services.
*/
public ConnectionService() {
super(TAG);
}
@Override
protected void onHandleIntent(Intent intent) {
mPort = intent.getIntExtra(EXTRA_TCPPORT, 9090);
mAddress = intent.getStringExtra(EXTRA_ADDRESS) != null ? intent.getStringExtra(EXTRA_ADDRESS) : "10.0.2.2";
long s = System.currentTimeMillis();
Log.d(TAG, "Starting TCP client...");
notifyStatus(MSG_CONNECTING, null);
Socket socket = null;
try {
final InetSocketAddress sockaddr = new InetSocketAddress(mAddress, mPort);
socket = new Socket();
mSocket = socket; // update class reference
socket.setSoTimeout(0); // no timeout for reading from connection.
socket.connect(sockaddr, SOCKET_TIMEOUT);
mOut = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
} catch (UnknownHostException e) {
Log.e(TAG, "Unknown host: " + e.getMessage(), e);
notifyError(new ApiException(ApiException.IO_UNKNOWN_HOST, "Unknown host: " + e.getMessage(), e), null);
stopSelf();
return;
} catch (SocketTimeoutException e) {
Log.e(TAG, "Unknown host: " + e.getMessage(), e);
notifyError(new ApiException(ApiException.IO_SOCKETTIMEOUT, "Connection timeout: " + e.getMessage(), e), null);
stopSelf();
return;
} catch (IOException e) {
Log.e(TAG, "I/O error while opening: " + e.getMessage(), e);
//notifyError(new ApiException(ApiException.IO_EXCEPTION_WHILE_OPENING, "I/O error while opening: " + e.getMessage(), e), null);
stopSelf();
return;
}
try {
Log.i(TAG, "Connected to TCP socket in " + (System.currentTimeMillis() - s) + "ms");
notifyStatus(MSG_CONNECTED, null);
// check for saved post data to send while we weren't connected,
// but do it in a separate thread so we can read already while sending.
if (!mPendingCalls.isEmpty()) {
new Thread("post-data-on-connection") {
@Override
public void run() {
final LinkedList<AbstractCall<?>> calls = mPendingCalls;
while (!calls.isEmpty()) {
writeSocket(calls.poll());
}
};
}.start();
}
final JsonFactory jf = OM.getJsonFactory();
final JsonParser jp = jf.createJsonParser(socket.getInputStream());
JsonNode node = null;
while ((node = OM.readTree(jp)) != null) {
if (node.toString().length() > 80) {
Log.i(TAG, "READ: " + node.toString().substring(0, 80) + "...");
} else {
Log.i(TAG, "READ: " + node.toString());
}
notifyClients(node);
}
mOut.close();
Log.i(TAG, "TCP socket closed.");
} catch (JsonParseException e) {
Log.e(TAG, "Cannot parse JSON response: " + e.getMessage(), e);
notifyError(new ApiException(ApiException.JSON_EXCEPTION, "Error while parsing JSON response: " + e.getMessage(), e), null);
} catch (EOFException e) {
Log.i(TAG, "Connection broken, quitting.");
notifyError(new ApiException(ApiException.IO_DISCONNECTED, "Socket disconnected: " + e.getMessage(), e), null);
} catch (IOException e) {
Log.e(TAG, "I/O error while reading (" + e.getClass().getSimpleName() + "): " + e.getMessage(), e);
//notifyError(new ApiException(ApiException.IO_EXCEPTION_WHILE_READING, "I/O error while reading: " + e.getMessage(), e), null);
} finally {
try {
if (socket != null) {
socket.close();
}
if (mOut != null) {
mOut.close();
}
} catch (IOException e) {
// do nothing.
}
}
}
@Override
public IBinder onBind(Intent intent) {
Log.i(TAG, "Connection service bound to new client.");
return mMessenger.getBinder();
}
@Override
public boolean onUnbind(Intent intent) {
final boolean ret = super.onUnbind(intent);
return ret;
}
@Override
public void onDestroy() {
super.onDestroy();
try {
final Socket socket = mSocket;
if (socket != null) {
if (socket.isConnected()) {
socket.shutdownInput();
}
if (!socket.isClosed()) {
socket.close();
}
}
} catch (IOException e) {
Log.e(TAG, "Error closing socket.", e);
}
Log.d(TAG, "Notification service destroyed.");
}
/**
* Starts cooldown. If there is no new client for {@link #COOLDOWN}
* milliseconds, the service will shutdown, otherwise it will continue
* to run until there is another cooldown.
*/
private void cooldown() {
Log.i(TAG, "Starting service cooldown.");
mCooldownTimer = new Timer();
mCooldownTimer.schedule(new TimerTask() {
@Override
public void run() {
if (mClients.isEmpty()) {
Log.i(TAG, "No new clients, shutting down service.");
stopSelf();
} else {
Log.i(TAG, "Cooldown failed, got " + mClients.size() + " new client(s).");
}
}
}, COOLDOWN);
}
/**
* Treats the received result.
* <p/>
* If an ID is found in the response, the API call object is retrieved,
* updated and sent back to the client.
* <p/>
* Otherwise, the notification object is sent to all clients.
*
* @param data JSON-serialized response
*/
private void notifyClients(JsonNode node) {
final ArrayList<Messenger> clients = mClients;
final HashMap<String, Messenger> map = mClientMap;
final HashMap<String, AbstractCall<?>> calls = mCalls;
final HashMap<String, JsonHandler> handlers = mHandlers;
// check for errors
if (node.has("error") && node.get("id") != null) {
notifyError(new ApiException(node), node.get("id").getValueAsText());
// check if notification or api call
} else if (node.has("id")) {
// it's api call.
final String id = node.get("id").getValueAsText();
if (calls.containsKey(id)) {
final AbstractCall<?> call = calls.get(id);
if (handlers.containsKey(id)) {
// we got an provided handler, so apply it and send back status message.
try {
handlers.get(id).applyResult(node, getContentResolver());
// get the right client to send back to
if (map.containsKey(id)) {
final Bundle b = new Bundle();
b.putString(EXTRA_CALLID, call.getId());
b.putInt(EXTRA_STATUS, RESULT_SUCCESS);
final Message msg = Message.obtain(null, MSG_RECEIVE_HANDLED_APICALL);
msg.setData(b);
try {
map.get(id).send(msg);
Log.i(TAG, "API call handled successfully, posting status back to client.");
} catch (RemoteException e) {
Log.e(TAG, "Error posting status back to client: " + e.getMessage(), e);
map.remove(id);
} finally {
// clean up
map.remove(id);
calls.remove(id);
}
} else {
Log.w(TAG, "Cannot find client in caller-mapping for " + id + ", dropping response (handled call).");
}
} catch (ApiException e) {
notifyError(e, id);
}
} else {
// get the right client to send back to
if (map.containsKey(id)) {
call.setResponse(node);
final Bundle b = new Bundle();
b.putParcelable(EXTRA_APICALL, call);
final Message msg = Message.obtain(null, MSG_RECEIVE_APICALL);
msg.setData(b);
try {
map.get(id).send(msg);
Log.i(TAG, "Sent updated API call " + call.getName() + " to client.");
} catch (RemoteException e) {
Log.e(TAG, "Error sending API response to client: " + e.getMessage(), e);
map.remove(id);
} finally {
// clean up
map.remove(id);
calls.remove(id);
}
} else {
Log.w(TAG, "Cannot find client in caller-mapping for " + id + ", dropping response (api call).");
}
}
} else {
Log.e(TAG, "Error: Cannot find API call with ID " + id + ".");
}
} else {
// it's a notification.
final AbstractEvent event = AbstractEvent.parse((ObjectNode) node);
if (event != null) {
Log.i(TAG, "Notifying " + clients.size() + " clients.");
for (int i = clients.size() - 1; i >= 0; i--) {
try {
final Bundle b = new Bundle();
b.putParcelable(EXTRA_NOTIFICATION, event);
final Message msg = Message.obtain(null, MSG_RECEIVE_NOTIFICATION);
msg.setData(b);
clients.get(i).send(msg);
} catch (RemoteException e) {
Log.e(TAG, "Cannot send notification to client: " + e.getMessage(), e);
/*
* The client is dead. Remove it from the list; we are
* going through the list from back to front so this is
* safe to do inside the loop.
*/
clients.remove(i);
// stopSelf();
}
}
} else {
Log.i(TAG, "Ignoring unknown notification " + node.get("method").getTextValue() + ".");
}
}
}
/**
* Sends an error to all clients.
* @param code Error code, see ERROR_*
* @param message Error message
* @param e Exception
*/
private void notifyError(ApiException e, String id) {
// if id is set and callback exists, only send error back to one client.
if (id != null && mClientMap.containsKey(id)) {
try {
final Message msg = Message.obtain(null, MSG_ERROR);
msg.setData(e.getBundle(getResources()));
mClientMap.get(id).send(msg);
Log.i(TAG, "Sent error to client with ID " + id + ".");
} catch (RemoteException e2) {
Log.e(TAG, "Cannot send errors to client " + id + ": "+ e.getMessage(), e2);
mClientMap.remove(id);
}
} else {
// otherwise, send error back to all clients and die.
for (int i = mClients.size() - 1; i >= 0; i--) {
final Message msg = Message.obtain(null, MSG_ERROR);
msg.setData(e.getBundle(getResources()));
try {
mClients.get(i).send(msg);
Log.i(TAG, "Sent error to client " + i + ".");
} catch (RemoteException e2) {
Log.e(TAG, "Cannot send errors to client: " + e2.getMessage(), e2);
/*
* The client is dead. Remove it from the list; we are going
* through the list from back to front so this is safe to do
* inside the loop.
*/
mClients.remove(i);
}
}
stopSelf();
}
}
private void notifyStatus(int code, Messenger replyTo) {
if (replyTo != null) {
try {
replyTo.send(Message.obtain(null, code));
} catch (RemoteException e) {
Log.e(TAG, "Could not notify sender of new status: " + e.getMessage(), e);
}
} else {
for (int i = mClients.size() - 1; i >= 0; i--) {
final Message msg = Message.obtain(null, code);
try {
mClients.get(i).send(msg);
} catch (RemoteException e2) {
Log.e(TAG, "Could not notify sender of new status: " + e2.getMessage(), e2);
mClients.remove(i);
}
}
}
}
/**
* Serializes the API request and dumps it on the socket.
* @param call
*/
private void writeSocket(AbstractCall<?> call) {
final String data = call.getRequest().toString();
Log.d(TAG, "Sending data to server.");
Log.d(TAG, "REQUEST: " + data);
try {
mOut.write(data + "\n");
mOut.flush();
} catch (IOException e) {
Log.e(TAG, "Error writing to socket: " + e.getMessage(), e);
notifyError(new ApiException(ApiException.IO_EXCEPTION_WHILE_WRITING, "I/O error while writing: " + e.getMessage(), e), call.getId());
}
}
/**
* Handler of incoming messages from clients.
*/
private class IncomingHandler extends Handler {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MSG_REGISTER_CLIENT:
mClients.add(msg.replyTo);
Log.d(TAG, "Registered new client.");
if (mCooldownTimer != null) {
Log.i(TAG, "Aborting cooldown timer.");
mCooldownTimer.cancel();
mCooldownTimer.purge();
}
if (mSocket != null && mSocket.isConnected()) {
Log.d(TAG, "Directly notifying connected status.");
notifyStatus(MSG_CONNECTED, msg.replyTo);
}
Log.d(TAG, "Done!");
break;
case MSG_UNREGISTER_CLIENT:
mClients.remove(msg.replyTo);
Log.d(TAG, "Unregistered client.");
if (mClients.size() == 0) {
Log.i(TAG, "No more clients, cooling down service.");
cooldown();
}
break;
case MSG_SEND_APICALL: {
Log.d(TAG, "Sending new API call..");
final Bundle data = msg.getData();
final AbstractCall<?> call = (AbstractCall<?>)data.getParcelable(EXTRA_APICALL);
mCalls.put(call.getId(), call);
mClientMap.put(call.getId(), msg.replyTo);
if (mOut == null) {
mPendingCalls.add(call);
} else {
writeSocket(call);
}
}
break;
case MSG_SEND_HANDLED_APICALL: {
Log.d(TAG, "Sending new handled API call..");
final Bundle data = msg.getData();
final AbstractCall<?> call = (AbstractCall<?>)data.getParcelable(EXTRA_APICALL);
final JsonHandler handler = (JsonHandler)data.getParcelable(EXTRA_HANDLER);
mCalls.put(call.getId(), call);
mHandlers.put(call.getId(), handler);
mClientMap.put(call.getId(), msg.replyTo);
if (mOut == null) {
Log.d(TAG, "Quering for later.");
mPendingCalls.add(call);
} else {
writeSocket(call);
}
}
break;
default:
super.handleMessage(msg);
}
}
}
}