1/*
2 * Copyright (C) 2014 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.service.media;
18
19import android.annotation.IntDef;
20import android.annotation.NonNull;
21import android.annotation.Nullable;
22import android.annotation.SdkConstant;
23import android.annotation.SdkConstant.SdkConstantType;
24import android.app.Service;
25import android.content.Intent;
26import android.content.pm.PackageManager;
27import android.content.pm.ParceledListSlice;
28import android.media.browse.MediaBrowser;
29import android.media.session.MediaSession;
30import android.os.Binder;
31import android.os.Bundle;
32import android.os.IBinder;
33import android.os.Handler;
34import android.os.RemoteException;
35import android.service.media.IMediaBrowserService;
36import android.service.media.IMediaBrowserServiceCallbacks;
37import android.util.ArrayMap;
38import android.util.Log;
39
40import java.io.FileDescriptor;
41import java.io.PrintWriter;
42import java.util.HashSet;
43import java.util.List;
44
45/**
46 * Base class for media browse services.
47 * <p>
48 * Media browse services enable applications to browse media content provided by an application
49 * and ask the application to start playing it.  They may also be used to control content that
50 * is already playing by way of a {@link MediaSession}.
51 * </p>
52 *
53 * To extend this class, you must declare the service in your manifest file with
54 * an intent filter with the {@link #SERVICE_INTERFACE} action.
55 *
56 * For example:
57 * </p><pre>
58 * &lt;service android:name=".MyMediaBrowserService"
59 *          android:label="&#64;string/service_name" >
60 *     &lt;intent-filter>
61 *         &lt;action android:name="android.media.browse.MediaBrowserService" />
62 *     &lt;/intent-filter>
63 * &lt;/service>
64 * </pre>
65 *
66 */
67public abstract class MediaBrowserService extends Service {
68    private static final String TAG = "MediaBrowserService";
69    private static final boolean DBG = false;
70
71    /**
72     * The {@link Intent} that must be declared as handled by the service.
73     */
74    @SdkConstant(SdkConstantType.SERVICE_ACTION)
75    public static final String SERVICE_INTERFACE = "android.media.browse.MediaBrowserService";
76
77    private final ArrayMap<IBinder, ConnectionRecord> mConnections = new ArrayMap();
78    private final Handler mHandler = new Handler();
79    private ServiceBinder mBinder;
80    MediaSession.Token mSession;
81
82    /**
83     * All the info about a connection.
84     */
85    private class ConnectionRecord {
86        String pkg;
87        Bundle rootHints;
88        IMediaBrowserServiceCallbacks callbacks;
89        BrowserRoot root;
90        HashSet<String> subscriptions = new HashSet();
91    }
92
93    /**
94     * Completion handler for asynchronous callback methods in {@link MediaBrowserService}.
95     * <p>
96     * Each of the methods that takes one of these to send the result must call
97     * {@link #sendResult} to respond to the caller with the given results.  If those
98     * functions return without calling {@link #sendResult}, they must instead call
99     * {@link #detach} before returning, and then may call {@link #sendResult} when
100     * they are done.  If more than one of those methods is called, an exception will
101     * be thrown.
102     *
103     * @see MediaBrowserService#onLoadChildren
104     */
105    public class Result<T> {
106        private Object mDebug;
107        private boolean mDetachCalled;
108        private boolean mSendResultCalled;
109
110        Result(Object debug) {
111            mDebug = debug;
112        }
113
114        /**
115         * Send the result back to the caller.
116         */
117        public void sendResult(T result) {
118            if (mSendResultCalled) {
119                throw new IllegalStateException("sendResult() called twice for: " + mDebug);
120            }
121            mSendResultCalled = true;
122            onResultSent(result);
123        }
124
125        /**
126         * Detach this message from the current thread and allow the {@link #sendResult}
127         * call to happen later.
128         */
129        public void detach() {
130            if (mDetachCalled) {
131                throw new IllegalStateException("detach() called when detach() had already"
132                        + " been called for: " + mDebug);
133            }
134            if (mSendResultCalled) {
135                throw new IllegalStateException("detach() called when sendResult() had already"
136                        + " been called for: " + mDebug);
137            }
138            mDetachCalled = true;
139        }
140
141        boolean isDone() {
142            return mDetachCalled || mSendResultCalled;
143        }
144
145        /**
146         * Called when the result is sent, after assertions about not being called twice
147         * have happened.
148         */
149        void onResultSent(T result) {
150        }
151    }
152
153    private class ServiceBinder extends IMediaBrowserService.Stub {
154        @Override
155        public void connect(final String pkg, final Bundle rootHints,
156                final IMediaBrowserServiceCallbacks callbacks) {
157
158            final int uid = Binder.getCallingUid();
159            if (!isValidPackage(pkg, uid)) {
160                throw new IllegalArgumentException("Package/uid mismatch: uid=" + uid
161                        + " package=" + pkg);
162            }
163
164            mHandler.post(new Runnable() {
165                    @Override
166                    public void run() {
167                        final IBinder b = callbacks.asBinder();
168
169                        // Clear out the old subscriptions.  We are getting new ones.
170                        mConnections.remove(b);
171
172                        final ConnectionRecord connection = new ConnectionRecord();
173                        connection.pkg = pkg;
174                        connection.rootHints = rootHints;
175                        connection.callbacks = callbacks;
176
177                        connection.root = MediaBrowserService.this.onGetRoot(pkg, uid, rootHints);
178
179                        // If they didn't return something, don't allow this client.
180                        if (connection.root == null) {
181                            Log.i(TAG, "No root for client " + pkg + " from service "
182                                    + getClass().getName());
183                            try {
184                                callbacks.onConnectFailed();
185                            } catch (RemoteException ex) {
186                                Log.w(TAG, "Calling onConnectFailed() failed. Ignoring. "
187                                        + "pkg=" + pkg);
188                            }
189                        } else {
190                            try {
191                                mConnections.put(b, connection);
192                                if (mSession != null) {
193                                    callbacks.onConnect(connection.root.getRootId(),
194                                            mSession, connection.root.getExtras());
195                                }
196                            } catch (RemoteException ex) {
197                                Log.w(TAG, "Calling onConnect() failed. Dropping client. "
198                                        + "pkg=" + pkg);
199                                mConnections.remove(b);
200                            }
201                        }
202                    }
203                });
204        }
205
206        @Override
207        public void disconnect(final IMediaBrowserServiceCallbacks callbacks) {
208            mHandler.post(new Runnable() {
209                    @Override
210                    public void run() {
211                        final IBinder b = callbacks.asBinder();
212
213                        // Clear out the old subscriptions.  We are getting new ones.
214                        final ConnectionRecord old = mConnections.remove(b);
215                        if (old != null) {
216                            // TODO
217                        }
218                    }
219                });
220        }
221
222
223        @Override
224        public void addSubscription(final String id, final IMediaBrowserServiceCallbacks callbacks) {
225            mHandler.post(new Runnable() {
226                    @Override
227                    public void run() {
228                        final IBinder b = callbacks.asBinder();
229
230                        // Get the record for the connection
231                        final ConnectionRecord connection = mConnections.get(b);
232                        if (connection == null) {
233                            Log.w(TAG, "addSubscription for callback that isn't registered id="
234                                + id);
235                            return;
236                        }
237
238                        MediaBrowserService.this.addSubscription(id, connection);
239                    }
240                });
241        }
242
243        @Override
244        public void removeSubscription(final String id,
245                final IMediaBrowserServiceCallbacks callbacks) {
246            mHandler.post(new Runnable() {
247                @Override
248                public void run() {
249                    final IBinder b = callbacks.asBinder();
250
251                    ConnectionRecord connection = mConnections.get(b);
252                    if (connection == null) {
253                        Log.w(TAG, "removeSubscription for callback that isn't registered id="
254                                + id);
255                        return;
256                    }
257                    if (!connection.subscriptions.remove(id)) {
258                        Log.w(TAG, "removeSubscription called for " + id
259                                + " which is not subscribed");
260                    }
261                }
262            });
263        }
264    }
265
266    @Override
267    public void onCreate() {
268        super.onCreate();
269        mBinder = new ServiceBinder();
270    }
271
272    @Override
273    public IBinder onBind(Intent intent) {
274        if (SERVICE_INTERFACE.equals(intent.getAction())) {
275            return mBinder;
276        }
277        return null;
278    }
279
280    @Override
281    public void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
282    }
283
284    /**
285     * Called to get the root information for browsing by a particular client.
286     * <p>
287     * The implementation should verify that the client package has
288     * permission to access browse media information before returning
289     * the root id; it should return null if the client is not
290     * allowed to access this information.
291     * </p>
292     *
293     * @param clientPackageName The package name of the application
294     * which is requesting access to browse media.
295     * @param clientUid The uid of the application which is requesting
296     * access to browse media.
297     * @param rootHints An optional bundle of service-specific arguments to send
298     * to the media browse service when connecting and retrieving the root id
299     * for browsing, or null if none.  The contents of this bundle may affect
300     * the information returned when browsing.
301     */
302    public abstract @Nullable BrowserRoot onGetRoot(@NonNull String clientPackageName,
303            int clientUid, @Nullable Bundle rootHints);
304
305    /**
306     * Called to get information about the children of a media item.
307     * <p>
308     * Implementations must call result.{@link Result#sendResult result.sendResult} with the list
309     * of children. If loading the children will be an expensive operation that should be performed
310     * on another thread, result.{@link Result#detach result.detach} may be called before returning
311     * from this function, and then {@link Result#sendResult result.sendResult} called when
312     * the loading is complete.
313     *
314     * @param parentId The id of the parent media item whose
315     * children are to be queried.
316     * @return The list of children, or null if the id is invalid.
317     */
318    public abstract void onLoadChildren(@NonNull String parentId,
319            @NonNull Result<List<MediaBrowser.MediaItem>> result);
320
321    /**
322     * Call to set the media session.
323     * <p>
324     * This should be called as soon as possible during the service's startup.
325     * It may only be called once.
326     */
327    public void setSessionToken(final MediaSession.Token token) {
328        if (token == null) {
329            throw new IllegalArgumentException("Session token may not be null.");
330        }
331        if (mSession != null) {
332            throw new IllegalStateException("The session token has already been set.");
333        }
334        mSession = token;
335        mHandler.post(new Runnable() {
336            @Override
337            public void run() {
338                for (IBinder key : mConnections.keySet()) {
339                    ConnectionRecord connection = mConnections.get(key);
340                    try {
341                        connection.callbacks.onConnect(connection.root.getRootId(), token,
342                                connection.root.getExtras());
343                    } catch (RemoteException e) {
344                        Log.w(TAG, "Connection for " + connection.pkg + " is no longer valid.");
345                        mConnections.remove(key);
346                    }
347                }
348            }
349        });
350    }
351
352    /**
353     * Gets the session token, or null if it has not yet been created
354     * or if it has been destroyed.
355     */
356    public @Nullable MediaSession.Token getSessionToken() {
357        return mSession;
358    }
359
360    /**
361     * Notifies all connected media browsers that the children of
362     * the specified parent id have changed in some way.
363     * This will cause browsers to fetch subscribed content again.
364     *
365     * @param parentId The id of the parent media item whose
366     * children changed.
367     */
368    public void notifyChildrenChanged(@NonNull final String parentId) {
369        if (parentId == null) {
370            throw new IllegalArgumentException("parentId cannot be null in notifyChildrenChanged");
371        }
372        mHandler.post(new Runnable() {
373            @Override
374            public void run() {
375                for (IBinder binder : mConnections.keySet()) {
376                    ConnectionRecord connection = mConnections.get(binder);
377                    if (connection.subscriptions.contains(parentId)) {
378                        performLoadChildren(parentId, connection);
379                    }
380                }
381            }
382        });
383    }
384
385    /**
386     * Return whether the given package is one of the ones that is owned by the uid.
387     */
388    private boolean isValidPackage(String pkg, int uid) {
389        if (pkg == null) {
390            return false;
391        }
392        final PackageManager pm = getPackageManager();
393        final String[] packages = pm.getPackagesForUid(uid);
394        final int N = packages.length;
395        for (int i=0; i<N; i++) {
396            if (packages[i].equals(pkg)) {
397                return true;
398            }
399        }
400        return false;
401    }
402
403    /**
404     * Save the subscription and if it is a new subscription send the results.
405     */
406    private void addSubscription(String id, ConnectionRecord connection) {
407        // Save the subscription
408        final boolean added = connection.subscriptions.add(id);
409
410        // If this is a new subscription, send the results
411        if (added) {
412            performLoadChildren(id, connection);
413        }
414    }
415
416    /**
417     * Call onLoadChildren and then send the results back to the connection.
418     * <p>
419     * Callers must make sure that this connection is still connected.
420     */
421    private void performLoadChildren(final String parentId, final ConnectionRecord connection) {
422        final Result<List<MediaBrowser.MediaItem>> result
423                = new Result<List<MediaBrowser.MediaItem>>(parentId) {
424            @Override
425            void onResultSent(List<MediaBrowser.MediaItem> list) {
426                if (list == null) {
427                    throw new IllegalStateException("onLoadChildren sent null list for id "
428                            + parentId);
429                }
430                if (mConnections.get(connection.callbacks.asBinder()) != connection) {
431                    if (DBG) {
432                        Log.d(TAG, "Not sending onLoadChildren result for connection that has"
433                                + " been disconnected. pkg=" + connection.pkg + " id=" + parentId);
434                    }
435                    return;
436                }
437
438                final ParceledListSlice<MediaBrowser.MediaItem> pls = new ParceledListSlice(list);
439                try {
440                    connection.callbacks.onLoadChildren(parentId, pls);
441                } catch (RemoteException ex) {
442                    // The other side is in the process of crashing.
443                    Log.w(TAG, "Calling onLoadChildren() failed for id=" + parentId
444                            + " package=" + connection.pkg);
445                }
446            }
447        };
448
449        onLoadChildren(parentId, result);
450
451        if (!result.isDone()) {
452            throw new IllegalStateException("onLoadChildren must call detach() or sendResult()"
453                    + " before returning for package=" + connection.pkg + " id=" + parentId);
454        }
455    }
456
457    /**
458     * Contains information that the browser service needs to send to the client
459     * when first connected.
460     */
461    public static final class BrowserRoot {
462        final private String mRootId;
463        final private Bundle mExtras;
464
465        /**
466         * Constructs a browser root.
467         * @param rootId The root id for browsing.
468         * @param extras Any extras about the browser service.
469         */
470        public BrowserRoot(@NonNull String rootId, @Nullable Bundle extras) {
471            if (rootId == null) {
472                throw new IllegalArgumentException("The root id in BrowserRoot cannot be null. " +
473                        "Use null for BrowserRoot instead.");
474            }
475            mRootId = rootId;
476            mExtras = extras;
477        }
478
479        /**
480         * Gets the root id for browsing.
481         */
482        public String getRootId() {
483            return mRootId;
484        }
485
486        /**
487         * Gets any extras about the brwoser service.
488         */
489        public Bundle getExtras() {
490            return mExtras;
491        }
492    }
493}
494