/* * 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.io; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.os.*; import android.util.Log; import org.codehaus.jackson.JsonNode; import org.xbmc.android.jsonrpc.api.AbstractCall; import org.xbmc.android.jsonrpc.api.AbstractModel; import org.xbmc.android.jsonrpc.config.HostConfig; import org.xbmc.android.jsonrpc.notification.AbstractEvent; import org.xbmc.android.jsonrpc.notification.PlayerEvent; import org.xbmc.android.jsonrpc.notification.PlayerObserver; import org.xbmc.android.jsonrpc.notification.SystemEvent; import org.xbmc.android.jsonrpc.service.ConnectionService; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; /** * Provides simple access to XBMC's JSON-RPC API. * <p/> * It is used for two things: * <ol><li>Query JSON-API via persistent TCP socket or HTTP.</li> * <li>Subscribe to notification events from XBMC.</li></ol> * * TCP socket is used by default. If you want to force HTTP request, use * {@link #setPreferHTTP()}, which will use HTTP as transport layer and * not use the connection service but {@link JsonApiRequest}. * <p/> * * The TCP connection is managed by {@link ConnectionService}. The manager uses * a {@link Messenger} to communicate with the service using a {@link Handler} * on both sides (see {@link IncomingHandler}). * <p/> * * <h3>Serialization</h3> * Since we're dealing with a service, objects sent to and received by the * service must be either native types or {@link Parcelable}. For this reason, * our entire JSON-RPC library implements {@link Parcelable}. That includes * all classes extending {@link AbstractCall} as well as {@link AbstractModel}. * <p/> * Once the service receives the API call object, it queries XBMC with the * given JSON data and uses the Jackson parser to serialize the response * directly into a {@link JsonNode}. Since <tt>JsonNode</tt> is not parcelable, * the service directly converts it into our object model using the API call * object. The updated API call object is then sent back to * <tt>ConnectionManager</tt>. * * <h3>Synchronization</h3> * When syncing the local database we want to avoid the parcelization happening * when sending the response back from <tt>ConnectionService</tt> to * <tt>ConnectionManager</tt>. Therefore, <tt>call()</tt> can additionally * provide a {@link JsonHandler}, which will synchronize the local DB and only * respond with a status code instead of the whole response. * * <h3>Notifications</h3> * Every instance of {@link ConnectionManager} appears as a client on the * service's side. Upon reception of a notification, the service announces all * connected clients. If {@link ConnectionManager} has any registered * observers, they will be notified, otherwise the notification is dropped. * * <h3>Destruction</h3> * When an instance of ConnectionManager is not needed anymore, be sure to run * {@link #disconnect()} in order to un-bind the service and shut down the TCP * connection when it's not needed anymore. Re-using a disconnected instance * will re-bind automatically. Note that on error, the service is always * disconnected automatically. * * @author freezy <freezy@xbmc.org> */ public class ConnectionManager { private static final String TAG = ConnectionManager.class.getSimpleName(); private final static String HTTP_PATH = "/jsonrpc"; /** * Reference to context */ private final Context mContext; /** * True if bound to service, false otherwise. */ boolean mIsBound; /** * The reference through which we receive messages from the service */ private final Messenger mMessenger = new Messenger(new IncomingHandler()); /** * The reference through which we send messages to the service */ private Messenger mService = null; /** * List of observers listening to notifications. */ private final ArrayList<NotificationObserver> mObservers = new ArrayList<NotificationObserver>(); /** * List of currently processing API calls with handler. Key is the ID of the API call. */ private final HashMap<String, HandlerCallback> mHandlerCallbacks = new HashMap<String, HandlerCallback>(); /** * Since we can't return the de-serialized object from the service, put the * response back into the received one and return the received one. */ private final HashMap<String, CallRequest<?>> mCallRequests = new HashMap<String, CallRequest<?>>(); /** * When posting request data and the service isn't started yet, we need to * reschedule the post until the service is available. This list contains * the requests that are to sent upon service startup. */ private final LinkedList<AbstractCall<?>> mPendingCalls = new LinkedList<AbstractCall<?>>(); private final HashMap<String, JsonHandler> mPendingHandlers = new HashMap<String, JsonHandler>(); /** * XBMC host configuration */ private HostConfig mHost; /** * Path where data gets posted. */ private String mHttpPath = HTTP_PATH; /** * If true and HTTP port is set, use HTTP requests instead of the TCP service. */ private boolean mPreferHTTP = false; /** * Class constructor. * @param c Needed if the service needs to be started */ public ConnectionManager(Context c, HostConfig host) { mContext = c; mHost = host; } /** * Executes a JSON-RPC request with the full result in the callback. * @param call Call to execute * @param callback How to treat result * @param <T> Result type * @return This instance */ public <T> ConnectionManager call(final AbstractCall<T> call, final ApiCallback<T> callback) { return call(call, null, callback); } /** * Executes a JSON-RPC request with the full result in the callback, which is executed * on the provided handler. * @param call Call to execute * @param handler Result is posted on that handler * @param callback How to treat result * @param <T> Result type * @return This instance */ public <T> ConnectionManager call(final AbstractCall<T> call, final Handler handler, final ApiCallback<T> callback) { if (mPreferHTTP) { // spawn another thread for this new Thread(new Runnable() { @Override public void run() { try { // synchronously post, retrieve and parse response. call.setResponse(JsonApiRequest.execute(getUrl(), mHost.getUsername(), mHost.getPassword(), call.getRequest())); if (handler != null) { handler.post(new Runnable() { @Override public void run() { callback.onResponse(call); } }); } else { callback.onResponse(call); } } catch (final ApiException e) { if (handler != null) { handler.post(new Runnable() { @Override public void run() { callback.onError(e.getCode(), e.getDisplayableMessage(mContext), e.getHint(mContext)); } }); } else { callback.onError(e.getCode(), e.getDisplayableMessage(mContext), e.getHint(mContext)); } } } }).start(); } else { // start service if not yet started bindService(); mCallRequests.put(call.getId(), new CallRequest<T>(call, callback)); sendCall(call); } return this; } /** * Executes a JSON-RPC request where the handler is executed at the service * and the callback gets a status code only. * * @param call Call to execute * @param handler Handler to treat result * @param callback Callback to handle result, can be null. * @return Class instance */ public ConnectionManager call(AbstractCall<?> call, JsonHandler handler, HandlerCallback callback) { // start service if not yet started bindService(); mHandlerCallbacks.put(call.getId(), callback); sendCall(call, handler); return this; } /** * Adds a new notification observer. * @param observer New observer * @return Class instance */ public ConnectionManager registerObserver(NotificationObserver observer) { // start service if not yet started bindService(); mObservers.add(observer); return this; } /** * Removes a previously added observer. * @param observer Observer to remove * @return Class instance */ public ConnectionManager unregisterObserver(NotificationObserver observer) { final ArrayList<NotificationObserver> observers = mObservers; observers.remove(observer); // stop service if no more observers. if (observers.isEmpty() && mCallRequests.isEmpty() && mHandlerCallbacks.isEmpty()) { unbindService(); Log.i(TAG, "Service unbound."); } else { Log.w(TAG, "Still stuff waiting, not unbinding."); } return this; } /** * Returns true if HTTP is used instead of a permanent TCP socket. * @return True if HTTP is used, false otherwise. */ public boolean prefersHTTP() { return mPreferHTTP; } /** * Makes the connection manager use HTTP requests instead of the connection * service, which uses a permanent TCP socket. */ public void setPreferHTTP() { mPreferHTTP = true; } /** * Binds the connection to the notification service if not yet bound. */ private void bindService() { // start service if no observer and no api calls. if (!mIsBound) { Log.i(TAG, "Starting and binding service..."); final Intent connectionServiceIntent = new Intent(mContext, ConnectionService.class); connectionServiceIntent.putExtra(ConnectionService.EXTRA_ADDRESS, mHost.getAddress()); connectionServiceIntent.putExtra(ConnectionService.EXTRA_HTTPPORT, mHost.getHttpPort()); connectionServiceIntent.putExtra(ConnectionService.EXTRA_TCPPORT, mHost.getTcpPort()); mContext.startService(connectionServiceIntent); mContext.bindService(connectionServiceIntent, mConnection, Context.BIND_AUTO_CREATE); mIsBound = true; } } /** * Unbinds the connection from the notification service. This is done by * notifying the service first and then terminating the connection. */ private void unbindService() { if (mIsBound) { Log.d(TAG, "Unbinding service..."); // If we have received the service, and hence registered with it, // then now is the time to unregister. if (mService != null) { try { final Message msg = Message.obtain(null, ConnectionService.MSG_UNREGISTER_CLIENT); msg.replyTo = mMessenger; mService.send(msg); } catch (RemoteException e) { Log.e(TAG, "Error unregistering client: " + e.getMessage(), e); // There is nothing special we need to do if the service has // crashed. } } // Detach our existing connection. mContext.unbindService(mConnection); mIsBound = false; } else { Log.d(TAG, "Not unbinding already unbound service."); } } /** * Posts a API call to the service. * @param apiCall API call */ private void sendCall(AbstractCall<?> apiCall) { if (mService != null) { try { final Message msg = Message.obtain(null, ConnectionService.MSG_SEND_APICALL); final Bundle data = new Bundle(); data.putParcelable(ConnectionService.EXTRA_APICALL, apiCall); msg.setData(data); msg.replyTo = mMessenger; mService.send(msg); Log.i(TAG, "Posted API call service (with callback)."); } catch (RemoteException e) { Log.e(TAG, "Error posting message to service: " + e.getMessage(), e); } } else { // service not yet started, saving data: Log.i(TAG, "Saving post data for later."); mPendingCalls.add(apiCall); } } /** * Posts a new handled API call to the service. * @param apiCall API call * @param handler Handler to execute in the service */ private void sendCall(AbstractCall<?> apiCall, JsonHandler handler) { if (mService != null) { try { final Message msg = Message. obtain(null, ConnectionService.MSG_SEND_HANDLED_APICALL); final Bundle data = new Bundle(); data.putParcelable(ConnectionService.EXTRA_APICALL, apiCall); data.putParcelable(ConnectionService.EXTRA_HANDLER, handler); msg.setData(data); msg.replyTo = mMessenger; mService.send(msg); Log.i(TAG, "Posted handled API call service."); } catch (RemoteException e) { Log.e(TAG, "Error posting message to service: " + e.getMessage(), e); } } else { // service not yet started, saving data: Log.i(TAG, "Saving post data for later."); mPendingCalls.add(apiCall); mPendingHandlers.put(apiCall.getId(), handler); } } /** * Disconnects from the service. * * Run this as soon as there are no immediate calls to the API. Running it * when there are still requests in progress will cut off the callback (so * don't do that). However, notification listener will not be affected. Once * you run {@link #disconnect()}, you still can re-use the same object * later, since it will be reconnect to the service as soon as it's used. */ public void disconnect() { if (mObservers.isEmpty()) { unbindService(); } } /** * Connection used to communicate with the service. */ private final ServiceConnection mConnection = new ServiceConnection() { public void onServiceConnected(ComponentName className, IBinder service) { mService = new Messenger(service); Log.i(TAG, "Connected to service."); try { final Message msg = Message.obtain(null, ConnectionService.MSG_REGISTER_CLIENT); msg.replyTo = mMessenger; mService.send(msg); } catch (RemoteException e) { Log.e(TAG, "Error registering client: " + e.getMessage(), e); // In this case the service has crashed before we could even do // anything with it } // now check if there are lost requests: final LinkedList<AbstractCall<?>> calls = mPendingCalls; while (!calls.isEmpty()) { AbstractCall<?> call = calls.poll(); if (mPendingHandlers.containsKey(call.getId())) { Log.d(TAG, "Posting pending handled call " + call.getName() + "..."); final JsonHandler handler = mPendingHandlers.get(call.getId()); sendCall(call, handler); mPendingHandlers.remove(call.getId()); } else { Log.d(TAG, "Posting pending call " + call.getName() + " with callback..."); sendCall(call); } } } public void onServiceDisconnected(ComponentName className) { // This is called when the connection with the service has been // unexpectedly disconnected - process crashed. mService = null; Log.i(TAG, "Service disconnected."); } }; /** * Returns the path of the HTTP request. Default is {@link ConnectionManager#HTTP_PATH}. * @return HTTP path */ public String getHttpPath() { return mHttpPath; } /** * Sets the path of the HTTP request. Should start with slash and end without. * @param httpPath Path */ public void setHttpPath(String httpPath) { mHttpPath = httpPath; } /** * Updates the host config. * @param hostConfig New host config. */ public void setHostConfig(HostConfig hostConfig) { mHost = hostConfig; } /** * The handler from the receiving service. * <p> * In here we add the logic of what happens when we get messages from the * notification service. * * @author freezy <freezy@xbmc.org> */ private class IncomingHandler extends Handler { @Override public void handleMessage(Message msg) { Log.i(TAG, "Got message: " + msg.what); final HashMap<String, CallRequest<?>> callrequests = mCallRequests; final HashMap<String, HandlerCallback> handlercallbacks = mHandlerCallbacks; switch (msg.what) { // fully updated API call object case ConnectionService.MSG_RECEIVE_APICALL: { final AbstractCall<?> returnedApiCall = msg.getData().getParcelable(ConnectionService.EXTRA_APICALL); if (returnedApiCall != null) { if (callrequests.containsKey(returnedApiCall.getId())) { final CallRequest<?> callrequest = callrequests.get(returnedApiCall.getId()); callrequest.update(returnedApiCall); callrequest.respond(); callrequests.remove(returnedApiCall.getId()); Log.d(TAG, "Callback for " + returnedApiCall.getName() + " sent back to caller."); } else { Log.w(TAG, "Unknown ID " + returnedApiCall.getId() + " for " + returnedApiCall.getName() + ", dropping."); } } else { Log.e(TAG, "Error retrieving API call object from bundle."); } break; } // status code after handled api call case ConnectionService.MSG_RECEIVE_HANDLED_APICALL: { final Bundle b = msg.getData(); final String id = b.getString(ConnectionService.EXTRA_CALLID); if (handlercallbacks.containsKey(id)) { if (handlercallbacks.get(id) != null) { handlercallbacks.get(id).onFinish(); handlercallbacks.remove(id); } } else { Log.w(TAG, "Unknown ID " + id + " for handled callback, not notifying caller."); } break; } // notification case ConnectionService.MSG_RECEIVE_NOTIFICATION: { final Bundle b = msg.getData(); final AbstractEvent notification = b.getParcelable(ConnectionService.EXTRA_NOTIFICATION); final ArrayList<NotificationObserver> observers = mObservers; for (NotificationObserver observer : observers) { switch (notification.getId()) { case PlayerEvent.Play.ID: observer.getPlayerObserver().onPlay((PlayerEvent.Play)notification); break; case PlayerEvent.Pause.ID: observer.getPlayerObserver().onPause((PlayerEvent.Pause)notification); break; case PlayerEvent.Stop.ID: observer.getPlayerObserver().onStop((PlayerEvent.Stop)notification); break; case PlayerEvent.SpeedChanged.ID: observer.getPlayerObserver().onSpeedChanged((PlayerEvent.SpeedChanged)notification); break; case PlayerEvent.Seek.ID: observer.getPlayerObserver().onSeek((PlayerEvent.Seek)notification); break; case SystemEvent.Quit.ID: case SystemEvent.Restart.ID: case SystemEvent.Wake.ID: case SystemEvent.LowBattery.ID: default: break; } } break; } // service started connecting to socket case ConnectionService.MSG_CONNECTING: { // we don't care for this right now break; } // service is connected to socket case ConnectionService.MSG_CONNECTED: { final ArrayList<NotificationObserver> observers = mObservers; for (NotificationObserver observer : observers) { observer.onConnected(); } break; } // shit happened case ConnectionService.MSG_ERROR: { final Bundle b = msg.getData(); final int code = b.getInt(ApiException.EXTRA_ERROR_CODE); final String message = b.getString(ApiException.EXTRA_ERROR_MESSAGE); final String hint = b.getString(ApiException.EXTRA_ERROR_HINT); final String id = b.getString(ConnectionService.EXTRA_CALLID); final HashMap<String, HandlerCallback> handleCallbacks = mHandlerCallbacks; final HashMap<String, CallRequest<?>> callRequests = mCallRequests; if (id != null && handleCallbacks.containsKey(id)) { // if ID given and handler call back, announce to handler callback. Log.e(TAG, "Error, notifying one handler callback."); if (handleCallbacks.get(id) != null) { handleCallbacks.get(id).onError(message, hint); } } else if (id != null && callRequests.containsKey(id)) { // if ID given and api call back, announce error. Log.e(TAG, "Error, notifying one API callback."); callRequests.get(id).error(code, message, hint); } else { // otherwise, announce to all clients (callbacks, api callbacks and observers). Log.e(TAG, "Error, notifying everybody."); for (HandlerCallback handlerCallback : handleCallbacks.values()) { if (handlerCallback != null) { handlerCallback.onError(message, hint); } } handleCallbacks.clear(); for (CallRequest<?> callreq : callRequests.values()) { callreq.error(code, message, hint); } callRequests.clear(); final ArrayList<NotificationObserver> observers = mObservers; for (NotificationObserver observer : observers) { observer.onError(code, message, hint); } observers.clear(); unbindService(); } break; } default: super.handleMessage(msg); } } } /** * Returns the URL of XBMC to connect to. * * The URL already contains the JSON-RPC prefix, e.g.: * <code>http://192.168.0.100:8080/jsonrpc</code> * @return URL of JSON-RPC via HTTP */ private String getUrl() { return "http://" + mHost.getAddress() + ":" + mHost.getHttpPort()+ mHttpPath; } /** * A call request bundles an API call and its callback of the same type. * * @author freezy <freezy@xbmc.org> */ private static class CallRequest<T> { private final AbstractCall<T> mCall; private final ApiCallback<T> mCallback; public CallRequest(AbstractCall<T> call, ApiCallback<T> callback) { this.mCall = call; this.mCallback = callback; } public void update(AbstractCall<?> call) { mCall.copyResponse(call); } public void respond() { mCallback.onResponse(mCall); } public void error(int code, String message, String hint) { mCallback.onError(code, message, hint); } } /** * Observer interface that handles arriving notifications. * @author freezy <freezy@xbmc.org> */ public static interface NotificationObserver { /** * Handle an arriving notification in here. */ public PlayerObserver getPlayerObserver(); /** * The service is connected to JSON-RPC's TCP socket. * <p/> * If the service was already connected, this will be sent immediately * after registering the client. */ public void onConnected(); /** * An error has occurred which resulted in the termination of the * connection. * @param code Error code, see constants at {@link ApiException}. * @param message Translated error message * @param hint Translated hint what the problem could be */ public void onError(int code, String message, String hint); } /** * When providing a {@link JsonHandler} to an API call, this interface * will inform the caller when processing has finished. * * @author freezy <freezy@xbmc.org> */ public static interface HandlerCallback { /** * Processing has successfully finished. * <p> * Don't forget to run {@link ConnectionManager#disconnect()} in here * if you don't immediately need to run another call. */ public void onFinish(); /** * Processing has failed. * <p> * Note that the service has been automatically disconnected. * @param message Translated error message * @param hint Translated hint */ public void onError(String message, String hint); } }