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
17package androidx.media.session;
18
19import static androidx.annotation.RestrictTo.Scope.LIBRARY;
20
21import android.app.PendingIntent;
22import android.app.Service;
23import android.content.BroadcastReceiver;
24import android.content.ComponentName;
25import android.content.Context;
26import android.content.Intent;
27import android.content.pm.PackageManager;
28import android.content.pm.ResolveInfo;
29import android.os.Build;
30import android.os.RemoteException;
31import android.support.v4.media.MediaBrowserCompat;
32import android.support.v4.media.session.MediaControllerCompat;
33import android.support.v4.media.session.MediaSessionCompat;
34import android.support.v4.media.session.PlaybackStateCompat;
35import android.support.v4.media.session.PlaybackStateCompat.MediaKeyAction;
36import android.util.Log;
37import android.view.KeyEvent;
38
39import androidx.annotation.RestrictTo;
40import androidx.media.MediaBrowserServiceCompat;
41
42import 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 */
100public class MediaButtonReceiver extends BroadcastReceiver {
101    private static final String TAG = "MediaButtonReceiver";
102
103    @Override
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
143        MediaButtonConnectionCallback(Context context, Intent intent, PendingResult pendingResult) {
144            mContext = context;
145            mIntent = intent;
146            mPendingResult = pendingResult;
147        }
148
149        void setMediaBrowser(MediaBrowserCompat mediaBrowser) {
150            mMediaBrowser = mediaBrowser;
151        }
152
153        @Override
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
167        public void onConnectionSuspended() {
168            finish();
169        }
170
171        @Override
172        public void onConnectionFailed() {
173            finish();
174        }
175
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     */
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     */
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     */
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)
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
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
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