1 /*
2  * Copyright 2018 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 
17 package androidx.media.session;
18 
19 import static androidx.annotation.RestrictTo.Scope.LIBRARY;
20 
21 import android.app.PendingIntent;
22 import android.app.Service;
23 import android.content.BroadcastReceiver;
24 import android.content.ComponentName;
25 import android.content.Context;
26 import android.content.Intent;
27 import android.content.pm.PackageManager;
28 import android.content.pm.ResolveInfo;
29 import android.os.Build;
30 import android.os.RemoteException;
31 import android.support.v4.media.MediaBrowserCompat;
32 import android.support.v4.media.session.MediaControllerCompat;
33 import android.support.v4.media.session.MediaSessionCompat;
34 import android.support.v4.media.session.PlaybackStateCompat;
35 import android.support.v4.media.session.PlaybackStateCompat.MediaKeyAction;
36 import android.util.Log;
37 import android.view.KeyEvent;
38 
39 import androidx.annotation.RestrictTo;
40 import androidx.media.MediaBrowserServiceCompat;
41 
42 import java.util.List;
43 
44 /**
45  * A media button receiver receives and helps translate hardware media playback buttons, such as
46  * those found on wired and wireless headsets, into the appropriate callbacks in your app.
47  * <p />
48  * You can add this MediaButtonReceiver to your app by adding it directly to your
49  * AndroidManifest.xml:
50  * <pre>
51  * &lt;receiver android:name="androidx.media.session.MediaButtonReceiver" &gt;
52  *   &lt;intent-filter&gt;
53  *     &lt;action android:name="android.intent.action.MEDIA_BUTTON" /&gt;
54  *   &lt;/intent-filter&gt;
55  * &lt;/receiver&gt;
56  * </pre>
57  *
58  * This class assumes you have a {@link Service} in your app that controls media playback via a
59  * {@link MediaSessionCompat}. Once a key event is received by MediaButtonReceiver, this class tries
60  * to find a {@link Service} that can handle {@link Intent#ACTION_MEDIA_BUTTON}, and a
61  * {@link MediaBrowserServiceCompat} in turn. If an appropriate service is found, this class
62  * forwards the key event to the service. If neither is available or more than one valid
63  * service/media browser service is found, an {@link IllegalStateException} will be thrown. Thus,
64  * your app should have one of the following services to get a key event properly.
65  * <p />
66  *
67  * <h4>Service Handling ACTION_MEDIA_BUTTON</h4>
68  * A service can receive a key event by including an intent filter that handles
69  * {@link Intent#ACTION_MEDIA_BUTTON}:
70  * <pre>
71  * &lt;service android:name="com.example.android.MediaPlaybackService" &gt;
72  *   &lt;intent-filter&gt;
73  *     &lt;action android:name="android.intent.action.MEDIA_BUTTON" /&gt;
74  *   &lt;/intent-filter&gt;
75  * &lt;/service&gt;
76  * </pre>
77  *
78  * Events can then be handled in {@link Service#onStartCommand(Intent, int, int)} by calling
79  * {@link MediaButtonReceiver#handleIntent(MediaSessionCompat, Intent)}, passing in your current
80  * {@link MediaSessionCompat}:
81  * <pre>
82  * private MediaSessionCompat mMediaSessionCompat = ...;
83  *
84  * public int onStartCommand(Intent intent, int flags, int startId) {
85  *   MediaButtonReceiver.handleIntent(mMediaSessionCompat, intent);
86  *   return super.onStartCommand(intent, flags, startId);
87  * }
88  * </pre>
89  *
90  * This ensures that the correct callbacks to {@link MediaSessionCompat.Callback} will be triggered
91  * based on the incoming {@link KeyEvent}.
92  * <p class="note"><strong>Note:</strong> Once the service is started, it must start to run in the
93  * foreground.</p>
94  *
95  * <h4>MediaBrowserService</h4>
96  * If you already have a {@link MediaBrowserServiceCompat} in your app, MediaButtonReceiver will
97  * deliver the received key events to the {@link MediaBrowserServiceCompat} by default. You can
98  * handle them in your {@link MediaSessionCompat.Callback}.
99  */
100 public class MediaButtonReceiver extends BroadcastReceiver {
101     private static final String TAG = "MediaButtonReceiver";
102 
103     @Override
onReceive(Context context, Intent intent)104     public void onReceive(Context context, Intent intent) {
105         if (intent == null
106                 || !Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
107                 || !intent.hasExtra(Intent.EXTRA_KEY_EVENT)) {
108             Log.d(TAG, "Ignore unsupported intent: " + intent);
109             return;
110         }
111         ComponentName mediaButtonServiceComponentName =
112                 getServiceComponentByAction(context, Intent.ACTION_MEDIA_BUTTON);
113         if (mediaButtonServiceComponentName != null) {
114             intent.setComponent(mediaButtonServiceComponentName);
115             startForegroundService(context, intent);
116             return;
117         }
118         ComponentName mediaBrowserServiceComponentName = getServiceComponentByAction(context,
119                 MediaBrowserServiceCompat.SERVICE_INTERFACE);
120         if (mediaBrowserServiceComponentName != null) {
121             PendingResult pendingResult = goAsync();
122             Context applicationContext = context.getApplicationContext();
123             MediaButtonConnectionCallback connectionCallback =
124                     new MediaButtonConnectionCallback(applicationContext, intent, pendingResult);
125             MediaBrowserCompat mediaBrowser = new MediaBrowserCompat(applicationContext,
126                     mediaBrowserServiceComponentName, connectionCallback, null);
127             connectionCallback.setMediaBrowser(mediaBrowser);
128             mediaBrowser.connect();
129             return;
130         }
131         throw new IllegalStateException("Could not find any Service that handles "
132                 + Intent.ACTION_MEDIA_BUTTON + " or implements a media browser service.");
133     }
134 
135     private static class MediaButtonConnectionCallback extends
136             MediaBrowserCompat.ConnectionCallback {
137         private final Context mContext;
138         private final Intent mIntent;
139         private final PendingResult mPendingResult;
140 
141         private MediaBrowserCompat mMediaBrowser;
142 
MediaButtonConnectionCallback(Context context, Intent intent, PendingResult pendingResult)143         MediaButtonConnectionCallback(Context context, Intent intent, PendingResult pendingResult) {
144             mContext = context;
145             mIntent = intent;
146             mPendingResult = pendingResult;
147         }
148 
setMediaBrowser(MediaBrowserCompat mediaBrowser)149         void setMediaBrowser(MediaBrowserCompat mediaBrowser) {
150             mMediaBrowser = mediaBrowser;
151         }
152 
153         @Override
onConnected()154         public void onConnected() {
155             try {
156                 MediaControllerCompat mediaController = new MediaControllerCompat(mContext,
157                         mMediaBrowser.getSessionToken());
158                 KeyEvent ke = mIntent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
159                 mediaController.dispatchMediaButtonEvent(ke);
160             } catch (RemoteException e) {
161                 Log.e(TAG, "Failed to create a media controller", e);
162             }
163             finish();
164         }
165 
166         @Override
onConnectionSuspended()167         public void onConnectionSuspended() {
168             finish();
169         }
170 
171         @Override
onConnectionFailed()172         public void onConnectionFailed() {
173             finish();
174         }
175 
finish()176         private void finish() {
177             mMediaBrowser.disconnect();
178             mPendingResult.finish();
179         }
180     };
181 
182     /**
183      * Extracts any available {@link KeyEvent} from an {@link Intent#ACTION_MEDIA_BUTTON}
184      * intent, passing it onto the {@link MediaSessionCompat} using
185      * {@link MediaControllerCompat#dispatchMediaButtonEvent(KeyEvent)}, which in turn
186      * will trigger callbacks to the {@link MediaSessionCompat.Callback} registered via
187      * {@link MediaSessionCompat#setCallback(MediaSessionCompat.Callback)}.
188      * @param mediaSessionCompat A {@link MediaSessionCompat} that has a
189      *            {@link MediaSessionCompat.Callback} set.
190      * @param intent The intent to parse.
191      * @return The extracted {@link KeyEvent} if found, or null.
192      */
handleIntent(MediaSessionCompat mediaSessionCompat, Intent intent)193     public static KeyEvent handleIntent(MediaSessionCompat mediaSessionCompat, Intent intent) {
194         if (mediaSessionCompat == null || intent == null
195                 || !Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
196                 || !intent.hasExtra(Intent.EXTRA_KEY_EVENT)) {
197             return null;
198         }
199         KeyEvent ke = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
200         MediaControllerCompat mediaController = mediaSessionCompat.getController();
201         mediaController.dispatchMediaButtonEvent(ke);
202         return ke;
203     }
204 
205     /**
206      * Creates a broadcast pending intent that will send a media button event. The {@code action}
207      * will be translated to the appropriate {@link KeyEvent}, and it will be sent to the
208      * registered media button receiver in the given context. The {@code action} should be one of
209      * the following:
210      * <ul>
211      * <li>{@link PlaybackStateCompat#ACTION_PLAY}</li>
212      * <li>{@link PlaybackStateCompat#ACTION_PAUSE}</li>
213      * <li>{@link PlaybackStateCompat#ACTION_SKIP_TO_NEXT}</li>
214      * <li>{@link PlaybackStateCompat#ACTION_SKIP_TO_PREVIOUS}</li>
215      * <li>{@link PlaybackStateCompat#ACTION_STOP}</li>
216      * <li>{@link PlaybackStateCompat#ACTION_FAST_FORWARD}</li>
217      * <li>{@link PlaybackStateCompat#ACTION_REWIND}</li>
218      * <li>{@link PlaybackStateCompat#ACTION_PLAY_PAUSE}</li>
219      * </ul>
220      *
221      * @param context The context of the application.
222      * @param action The action to be sent via the pending intent.
223      * @return Created pending intent, or null if cannot find a unique registered media button
224      *         receiver or if the {@code action} is unsupported/invalid.
225      */
buildMediaButtonPendingIntent(Context context, @MediaKeyAction long action)226     public static PendingIntent buildMediaButtonPendingIntent(Context context,
227             @MediaKeyAction long action) {
228         ComponentName mbrComponent = getMediaButtonReceiverComponent(context);
229         if (mbrComponent == null) {
230             Log.w(TAG, "A unique media button receiver could not be found in the given context, so "
231                     + "couldn't build a pending intent.");
232             return null;
233         }
234         return buildMediaButtonPendingIntent(context, mbrComponent, action);
235     }
236 
237     /**
238      * Creates a broadcast pending intent that will send a media button event. The {@code action}
239      * will be translated to the appropriate {@link KeyEvent}, and sent to the provided media
240      * button receiver via the pending intent. The {@code action} should be one of the following:
241      * <ul>
242      * <li>{@link PlaybackStateCompat#ACTION_PLAY}</li>
243      * <li>{@link PlaybackStateCompat#ACTION_PAUSE}</li>
244      * <li>{@link PlaybackStateCompat#ACTION_SKIP_TO_NEXT}</li>
245      * <li>{@link PlaybackStateCompat#ACTION_SKIP_TO_PREVIOUS}</li>
246      * <li>{@link PlaybackStateCompat#ACTION_STOP}</li>
247      * <li>{@link PlaybackStateCompat#ACTION_FAST_FORWARD}</li>
248      * <li>{@link PlaybackStateCompat#ACTION_REWIND}</li>
249      * <li>{@link PlaybackStateCompat#ACTION_PLAY_PAUSE}</li>
250      * </ul>
251      *
252      * @param context The context of the application.
253      * @param mbrComponent The full component name of a media button receiver where you want to send
254      *            this intent.
255      * @param action The action to be sent via the pending intent.
256      * @return Created pending intent, or null if the given component name is null or the
257      *         {@code action} is unsupported/invalid.
258      */
buildMediaButtonPendingIntent(Context context, ComponentName mbrComponent, @MediaKeyAction long action)259     public static PendingIntent buildMediaButtonPendingIntent(Context context,
260             ComponentName mbrComponent, @MediaKeyAction long action) {
261         if (mbrComponent == null) {
262             Log.w(TAG, "The component name of media button receiver should be provided.");
263             return null;
264         }
265         int keyCode = PlaybackStateCompat.toKeyCode(action);
266         if (keyCode == KeyEvent.KEYCODE_UNKNOWN) {
267             Log.w(TAG,
268                     "Cannot build a media button pending intent with the given action: " + action);
269             return null;
270         }
271         Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON);
272         intent.setComponent(mbrComponent);
273         intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
274         return PendingIntent.getBroadcast(context, keyCode, intent, 0);
275     }
276 
277     /**
278      * @hide
279      */
280     @RestrictTo(LIBRARY)
getMediaButtonReceiverComponent(Context context)281     public static ComponentName getMediaButtonReceiverComponent(Context context) {
282         Intent queryIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
283         queryIntent.setPackage(context.getPackageName());
284         PackageManager pm = context.getPackageManager();
285         List<ResolveInfo> resolveInfos = pm.queryBroadcastReceivers(queryIntent, 0);
286         if (resolveInfos.size() == 1) {
287             ResolveInfo resolveInfo = resolveInfos.get(0);
288             return new ComponentName(resolveInfo.activityInfo.packageName,
289                     resolveInfo.activityInfo.name);
290         } else if (resolveInfos.size() > 1) {
291             Log.w(TAG, "More than one BroadcastReceiver that handles "
292                     + Intent.ACTION_MEDIA_BUTTON + " was found, returning null.");
293         }
294         return null;
295     }
296 
startForegroundService(Context context, Intent intent)297     private static void startForegroundService(Context context, Intent intent) {
298         if (Build.VERSION.SDK_INT >= 26) {
299             context.startForegroundService(intent);
300         } else {
301             context.startService(intent);
302         }
303     }
304 
getServiceComponentByAction(Context context, String action)305     private static ComponentName getServiceComponentByAction(Context context, String action) {
306         PackageManager pm = context.getPackageManager();
307         Intent queryIntent = new Intent(action);
308         queryIntent.setPackage(context.getPackageName());
309         List<ResolveInfo> resolveInfos = pm.queryIntentServices(queryIntent, 0 /* flags */);
310         if (resolveInfos.size() == 1) {
311             ResolveInfo resolveInfo = resolveInfos.get(0);
312             return new ComponentName(resolveInfo.serviceInfo.packageName,
313                     resolveInfo.serviceInfo.name);
314         } else if (resolveInfos.isEmpty()) {
315             return null;
316         } else {
317             throw new IllegalStateException("Expected 1 service that handles " + action + ", found "
318                     + resolveInfos.size());
319         }
320     }
321 }
322