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     * @see MediaBrowserService#onLoadIcon
105     */
106    public class Result<T> {
107        private Object mDebug;
108        private boolean mDetachCalled;
109        private boolean mSendResultCalled;
110
111        Result(Object debug) {
112            mDebug = debug;
113        }
114
115        /**
116         * Send the result back to the caller.
117         */
118        public void sendResult(T result) {
119            if (mSendResultCalled) {
120                throw new IllegalStateException("sendResult() called twice for: " + mDebug);
121            }
122            mSendResultCalled = true;
123            onResultSent(result);
124        }
125
126        /**
127         * Detach this message from the current thread and allow the {@link #sendResult}
128         * call to happen later.
129         */
130        public void detach() {
131            if (mDetachCalled) {
132                throw new IllegalStateException("detach() called when detach() had already"
133                        + " been called for: " + mDebug);
134            }
135            if (mSendResultCalled) {
136                throw new IllegalStateException("detach() called when sendResult() had already"
137                        + " been called for: " + mDebug);
138            }
139            mDetachCalled = true;
140        }
141
142        boolean isDone() {
143            return mDetachCalled || mSendResultCalled;
144        }
145
146        /**
147         * Called when the result is sent, after assertions about not being called twice
148         * have happened.
149         */
150        void onResultSent(T result) {
151        }
152    }
153
154    private class ServiceBinder extends IMediaBrowserService.Stub {
155        @Override
156        public void connect(final String pkg, final Bundle rootHints,
157                final IMediaBrowserServiceCallbacks callbacks) {
158
159            final int uid = Binder.getCallingUid();
160            if (!isValidPackage(pkg, uid)) {
161                throw new IllegalArgumentException("Package/uid mismatch: uid=" + uid
162                        + " package=" + pkg);
163            }
164
165            mHandler.post(new Runnable() {
166                    @Override
167                    public void run() {
168                        final IBinder b = callbacks.asBinder();
169
170                        // Clear out the old subscriptions.  We are getting new ones.
171                        mConnections.remove(b);
172
173                        final ConnectionRecord connection = new ConnectionRecord();
174                        connection.pkg = pkg;
175                        connection.rootHints = rootHints;
176                        connection.callbacks = callbacks;
177
178                        connection.root = MediaBrowserService.this.onGetRoot(pkg, uid, rootHints);
179
180                        // If they didn't return something, don't allow this client.
181                        if (connection.root == null) {
182                            Log.i(TAG, "No root for client " + pkg + " from service "
183                                    + getClass().getName());
184                            try {
185                                callbacks.onConnectFailed();
186                            } catch (RemoteException ex) {
187                                Log.w(TAG, "Calling onConnectFailed() failed. Ignoring. "
188                                        + "pkg=" + pkg);
189                            }
190                        } else {
191                            try {
192                                mConnections.put(b, connection);
193                                callbacks.onConnect(connection.root.getRootId(),
194                                        mSession, connection.root.getExtras());
195                            } catch (RemoteException ex) {
196                                Log.w(TAG, "Calling onConnect() failed. Dropping client. "
197                                        + "pkg=" + pkg);
198                                mConnections.remove(b);
199                            }
200                        }
201                    }
202                });
203        }
204
205        @Override
206        public void disconnect(final IMediaBrowserServiceCallbacks callbacks) {
207            mHandler.post(new Runnable() {
208                    @Override
209                    public void run() {
210                        final IBinder b = callbacks.asBinder();
211
212                        // Clear out the old subscriptions.  We are getting new ones.
213                        final ConnectionRecord old = mConnections.remove(b);
214                        if (old != null) {
215                            // TODO
216                        }
217                    }
218                });
219        }
220
221
222        @Override
223        public void addSubscription(final String id, final IMediaBrowserServiceCallbacks callbacks) {
224            mHandler.post(new Runnable() {
225                    @Override
226                    public void run() {
227                        final IBinder b = callbacks.asBinder();
228
229                        // Get the record for the connection
230                        final ConnectionRecord connection = mConnections.get(b);
231                        if (connection == null) {
232                            Log.w(TAG, "addSubscription for callback that isn't registered id="
233                                + id);
234                            return;
235                        }
236
237                        MediaBrowserService.this.addSubscription(id, connection);
238                    }
239                });
240        }
241
242        @Override
243        public void removeSubscription(final String id,
244                final IMediaBrowserServiceCallbacks callbacks) {
245            mHandler.post(new Runnable() {
246                @Override
247                public void run() {
248                    final IBinder b = callbacks.asBinder();
249
250                    ConnectionRecord connection = mConnections.get(b);
251                    if (connection == null) {
252                        Log.w(TAG, "removeSubscription for callback that isn't registered id="
253                                + id);
254                        return;
255                    }
256                    if (!connection.subscriptions.remove(id)) {
257                        Log.w(TAG, "removeSubscription called for " + id
258                                + " which is not subscribed");
259                    }
260                }
261            });
262        }
263    }
264
265    @Override
266    public void onCreate() {
267        super.onCreate();
268        mBinder = new ServiceBinder();
269    }
270
271    @Override
272    public IBinder onBind(Intent intent) {
273        if (SERVICE_INTERFACE.equals(intent.getAction())) {
274            return mBinder;
275        }
276        return null;
277    }
278
279    @Override
280    public void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
281    }
282
283    /**
284     * Called to get the root information for browsing by a particular client.
285     * <p>
286     * The implementation should verify that the client package has
287     * permission to access browse media information before returning
288     * the root id; it should return null if the client is not
289     * allowed to access this information.
290     * </p>
291     *
292     * @param clientPackageName The package name of the application
293     * which is requesting access to browse media.
294     * @param clientUid The uid of the application which is requesting
295     * access to browse media.
296     * @param rootHints An optional bundle of service-specific arguments to send
297     * to the media browse service when connecting and retrieving the root id
298     * for browsing, or null if none.  The contents of this bundle may affect
299     * the information returned when browsing.
300     */
301    public abstract @Nullable BrowserRoot onGetRoot(@NonNull String clientPackageName,
302            int clientUid, @Nullable Bundle rootHints);
303
304    /**
305     * Called to get information about the children of a media item.
306     * <p>
307     * Implementations must call result.{@link Result#sendResult result.sendResult} with the list
308     * of children. If loading the children will be an expensive operation that should be performed
309     * on another thread, result.{@link Result#detach result.detach} may be called before returning
310     * from this function, and then {@link Result#sendResult result.sendResult} called when
311     * the loading is complete.
312     *
313     * @param parentId The id of the parent media item whose
314     * children are to be queried.
315     * @return The list of children, or null if the id is invalid.
316     */
317    public abstract void onLoadChildren(@NonNull String parentId,
318            @NonNull Result<List<MediaBrowser.MediaItem>> result);
319
320    /**
321     * Call to set the media session.
322     * <p>
323     * This must be called before onCreate returns.
324     *
325     * @return The media session token, must not be null.
326     */
327    public void setSessionToken(MediaSession.Token token) {
328        if (token == null) {
329            throw new IllegalStateException(this.getClass().getName()
330                    + ".onCreateSession() set invalid MediaSession.Token");
331        }
332        mSession = token;
333    }
334
335    /**
336     * Gets the session token, or null if it has not yet been created
337     * or if it has been destroyed.
338     */
339    public @Nullable MediaSession.Token getSessionToken() {
340        return mSession;
341    }
342
343    /**
344     * Notifies all connected media browsers that the children of
345     * the specified parent id have changed in some way.
346     * This will cause browsers to fetch subscribed content again.
347     *
348     * @param parentId The id of the parent media item whose
349     * children changed.
350     */
351    public void notifyChildrenChanged(@NonNull final String parentId) {
352        if (parentId == null) {
353            throw new IllegalArgumentException("parentId cannot be null in notifyChildrenChanged");
354        }
355        mHandler.post(new Runnable() {
356            @Override
357            public void run() {
358                for (IBinder binder : mConnections.keySet()) {
359                    ConnectionRecord connection = mConnections.get(binder);
360                    if (connection.subscriptions.contains(parentId)) {
361                        performLoadChildren(parentId, connection);
362                    }
363                }
364            }
365        });
366    }
367
368    /**
369     * Return whether the given package is one of the ones that is owned by the uid.
370     */
371    private boolean isValidPackage(String pkg, int uid) {
372        if (pkg == null) {
373            return false;
374        }
375        final PackageManager pm = getPackageManager();
376        final String[] packages = pm.getPackagesForUid(uid);
377        final int N = packages.length;
378        for (int i=0; i<N; i++) {
379            if (packages[i].equals(pkg)) {
380                return true;
381            }
382        }
383        return false;
384    }
385
386    /**
387     * Save the subscription and if it is a new subscription send the results.
388     */
389    private void addSubscription(String id, ConnectionRecord connection) {
390        // Save the subscription
391        final boolean added = connection.subscriptions.add(id);
392
393        // If this is a new subscription, send the results
394        if (added) {
395            performLoadChildren(id, connection);
396        }
397    }
398
399    /**
400     * Call onLoadChildren and then send the results back to the connection.
401     * <p>
402     * Callers must make sure that this connection is still connected.
403     */
404    private void performLoadChildren(final String parentId, final ConnectionRecord connection) {
405        final Result<List<MediaBrowser.MediaItem>> result
406                = new Result<List<MediaBrowser.MediaItem>>(parentId) {
407            @Override
408            void onResultSent(List<MediaBrowser.MediaItem> list) {
409                if (list == null) {
410                    throw new IllegalStateException("onLoadChildren sent null list for id "
411                            + parentId);
412                }
413                if (mConnections.get(connection.callbacks.asBinder()) != connection) {
414                    if (DBG) {
415                        Log.d(TAG, "Not sending onLoadChildren result for connection that has"
416                                + " been disconnected. pkg=" + connection.pkg + " id=" + parentId);
417                    }
418                    return;
419                }
420
421                final ParceledListSlice<MediaBrowser.MediaItem> pls = new ParceledListSlice(list);
422                try {
423                    connection.callbacks.onLoadChildren(parentId, pls);
424                } catch (RemoteException ex) {
425                    // The other side is in the process of crashing.
426                    Log.w(TAG, "Calling onLoadChildren() failed for id=" + parentId
427                            + " package=" + connection.pkg);
428                }
429            }
430        };
431
432        onLoadChildren(parentId, result);
433
434        if (!result.isDone()) {
435            throw new IllegalStateException("onLoadChildren must call detach() or sendResult()"
436                    + " before returning for package=" + connection.pkg + " id=" + parentId);
437        }
438    }
439
440    /**
441     * Contains information that the browser service needs to send to the client
442     * when first connected.
443     */
444    public static final class BrowserRoot {
445        final private String mRootId;
446        final private Bundle mExtras;
447
448        /**
449         * Constructs a browser root.
450         * @param rootId The root id for browsing.
451         * @param extras Any extras about the browser service.
452         */
453        public BrowserRoot(@NonNull String rootId, @Nullable Bundle extras) {
454            if (rootId == null) {
455                throw new IllegalArgumentException("The root id in BrowserRoot cannot be null. " +
456                        "Use null for BrowserRoot instead.");
457            }
458            mRootId = rootId;
459            mExtras = extras;
460        }
461
462        /**
463         * Gets the root id for browsing.
464         */
465        public String getRootId() {
466            return mRootId;
467        }
468
469        /**
470         * Gets any extras about the brwoser service.
471         */
472        public Bundle getExtras() {
473            return mExtras;
474        }
475    }
476}
477