/* * Copyright (C) 2017 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.googlecode.android_scripting.facade.bluetooth; import android.app.Service; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.media.MediaMetadata; import android.media.browse.MediaBrowser; import android.media.session.MediaController; import android.media.session.MediaSessionManager; import android.media.session.PlaybackState; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import com.googlecode.android_scripting.Log; import com.googlecode.android_scripting.facade.EventFacade; import com.googlecode.android_scripting.facade.FacadeManager; import com.googlecode.android_scripting.facade.bluetooth.media.BluetoothSL4AAudioSrcMBS; import com.googlecode.android_scripting.jsonrpc.RpcReceiver; import com.googlecode.android_scripting.rpc.Rpc; import com.googlecode.android_scripting.rpc.RpcParameter; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * SL4A Facade for running Bluetooth Media related test cases * The APIs provided here can be grouped into 3 categories: * 1. Those that can run on both an Audio Source and Sink * 2. Those that makes sense to run only on a Audio Source like a phone * 3. Those that makes sense to run only on a Audio Sink like a Car. * * This media test framework consists of 3 classes: * 1. BluetoothMediaFacade - this class that provides the APIs that a RPC client can interact with * 2. BluetoothSL4AMBS - This is a MediaBrowserService that is intended to run on the Audio Source * (phone). This MediaBrowserService that runs as part of the SL4A app is used to intercept * Media key events coming in from a AVRCP Controller like Car. Intercepting these events lets us * instrument the Bluetooth media related tests. * 3. BluetoothMediaPlayback - The class that the MediaBrowserService uses to play media files. * It is a UI-less MediaPlayer that serves the purpose of Bluetooth Media testing. * * The idea is for the BluetoothMediaFacade to create a BluetoothSL4AMBS MediaSession on the * Phone (Bluetooth Audio source/Avrcp Target) and use it intercept the Media commands coming * from the CarKitt (Bluetooth Audio Sink / Avrcp Controller). * On the Carkitt side, we just create and connect a MediaBrowser to the * BluetoothMediaBrowserService that is part of the Carkitt's Bluetooth Audio App. We use this * browser to send media commands to the Phone side and intercept the commands with the * BluetoothSL4AMBS. * This set up helps to instrument tests that can test various Bluetooth Media usecases. */ public class BluetoothMediaFacade extends RpcReceiver { private static final String TAG = "BluetoothMediaFacade"; private static final boolean VDBG = false; private final Service mService; private final Context mContext; private Handler mHandler; private MediaSessionManager mSessionManager; private MediaController mMediaController = null; private MediaController.Callback mMediaCtrlCallback = null; private MediaSessionManager.OnActiveSessionsChangedListener mSessionListener; private MediaBrowser mBrowser = null; private static EventFacade mEventFacade; // Events posted private static final String EVENT_PLAY_RECEIVED = "playReceived"; private static final String EVENT_PAUSE_RECEIVED = "pauseReceived"; private static final String EVENT_SKIP_PREV_RECEIVED = "skipPrevReceived"; private static final String EVENT_SKIP_NEXT_RECEIVED = "skipNextReceived"; // Commands received private static final String CMD_MEDIA_PLAY = "play"; private static final String CMD_MEDIA_PAUSE = "pause"; private static final String CMD_MEDIA_SKIP_NEXT = "skipNext"; private static final String CMD_MEDIA_SKIP_PREV = "skipPrev"; private static final String BLUETOOTH_PKG_NAME = "com.android.bluetooth"; private static final String BROWSER_SERVICE_NAME = "com.android.bluetooth.avrcpcontroller.BluetoothMediaBrowserService"; private static final String BLUETOOTH_MBS_TAG = "BluetoothMediaBrowserService"; // MediaMetadata keys private static final String MEDIA_KEY_TITLE = "keyTitle"; private static final String MEDIA_KEY_ALBUM = "keyAlbum"; private static final String MEDIA_KEY_ARTIST = "keyArtist"; private static final String MEDIA_KEY_DURATION = "keyDuration"; private static final String MEDIA_KEY_NUM_TRACKS = "keyNumTracks"; /** * Following things are initialized here: * 1. Setup Listeners to Active Media Session changes * 2. Create a new MediaController.callback instance */ public BluetoothMediaFacade(FacadeManager manager) { super(manager); mService = manager.getService(); mEventFacade = manager.getReceiver(EventFacade.class); mHandler = new Handler(Looper.getMainLooper()); mContext = mService.getApplicationContext(); mSessionManager = (MediaSessionManager) mContext.getSystemService(mContext.MEDIA_SESSION_SERVICE); mSessionListener = new SessionChangeListener(); // Listen on Active MediaSession changes, so we can get the active session's MediaController if (mSessionManager != null) { ComponentName compName = new ComponentName(mContext.getPackageName(), this.getClass().getName()); mSessionManager.addOnActiveSessionsChangedListener(mSessionListener, null, mHandler); if (VDBG) { List mcl = mSessionManager.getActiveSessions(null); Log.d(TAG + " Num Sessions " + mcl.size()); for (int i = 0; i < mcl.size(); i++) { Log.d(TAG + "Active session : " + i + ((MediaController) (mcl.get( i))).getPackageName() + ((MediaController) (mcl.get(i))).getTag()); } } } mMediaCtrlCallback = new MediaControllerCallback(); } /** * The listener that was setup for listening to changes to Active Media Sessions. * This listener is useful in both Car and Phone sides. */ private class SessionChangeListener implements MediaSessionManager.OnActiveSessionsChangedListener { /** * On the Phone side, it listens to the BluetoothSL4AAudioSrcMBS (that the SL4A app runs) * becoming active. * On the Car side, it listens to the BluetoothMediaBrowserService (associated with the * Bluetooth Audio App) becoming active. * The idea is to get a handle to the MediaController appropriate for the device, so * that we can send and receive Media commands. */ @Override public void onActiveSessionsChanged(List controllers) { if (VDBG) { Log.d(TAG + " onActiveSessionsChanged : " + controllers.size()); for (int i = 0; i < controllers.size(); i++) { Log.d(TAG + "Active session : " + i + ((MediaController) (controllers.get( i))).getPackageName() + ((MediaController) (controllers.get( i))).getTag()); } } // As explained above, looking for the BluetoothSL4AAudioSrcMBS (when running on Phone) // or BluetoothMediaBrowserService (when running on Carkitt). for (int i = 0; i < controllers.size(); i++) { MediaController controller = (MediaController) controllers.get(i); if ((controller.getTag().contains(BluetoothSL4AAudioSrcMBS.getTag())) || (controller.getTag().contains(BLUETOOTH_MBS_TAG))) { setCurrentMediaController(controller); return; } } } } /** * When the MediaController for the required MediaSession is obtained, register for its * callbacks. * Not used yet, but this can be used to verify state changes in both ends. */ private class MediaControllerCallback extends MediaController.Callback { @Override public void onPlaybackStateChanged(PlaybackState state) { Log.d(TAG + " onPlaybackStateChanged: " + state.getState()); } @Override public void onMetadataChanged(MediaMetadata metadata) { Log.d(TAG + " onMetadataChanged "); } } /** * Callback on MediaBrowser.connect() * This is relevant only on the Carkitt side, since the intent is to connect a MediaBrowser * to the BluetoothMediaBrowserService that is run by the Car's Bluetooth Audio App. * On successful connection, we obtain the handle to the corresponding MediaController, * so we can imitate sending media commands via the Bluetooth Audio App. */ MediaBrowser.ConnectionCallback mBrowserConnectionCallback = new MediaBrowser.ConnectionCallback() { private static final String classTag = TAG + " BrowserConnectionCallback"; @Override public void onConnected() { Log.d(classTag + " onConnected: session token " + mBrowser.getSessionToken()); MediaController mediaController = new MediaController(mContext, mBrowser.getSessionToken()); // Update the MediaController setCurrentMediaController(mediaController); } @Override public void onConnectionFailed() { Log.d(classTag + " onConnectionFailed"); } }; /** * Update the Current MediaController. * As has been commented above, we need the MediaController handles to the * BluetoothSL4AAudioSrcMBS on Phone and BluetoothMediaBrowserService on Car to send and receive * media commands. * * @param controller - Controller to update with */ private void setCurrentMediaController(MediaController controller) { Handler mainHandler = new Handler(mContext.getMainLooper()); if (mMediaController == null && controller != null) { Log.d(TAG + " Setting MediaController " + controller.getTag()); mMediaController = controller; mMediaController.registerCallback(mMediaCtrlCallback); } else if (mMediaController != null && controller != null) { // We have a new MediaController that we have to update to. if (controller.getSessionToken().equals(mMediaController.getSessionToken()) == false) { Log.d(TAG + " Changing MediaController " + controller.getTag()); mMediaController.unregisterCallback(mMediaCtrlCallback); mMediaController = controller; mMediaController.registerCallback(mMediaCtrlCallback, mainHandler); } } else if (mMediaController != null && controller == null) { // Clearing the current MediaController Log.d(TAG + " Clearing MediaController " + mMediaController.getTag()); mMediaController.unregisterCallback(mMediaCtrlCallback); mMediaController = controller; } } /** * Class method called from {@link BluetoothSL4AAudioSrcMBS} to post an Event through * EventFacade back to the RPC client. * This is dispatched from the Phone to the host (RPC Client) to acknowledge that it * received a playback command. * * @param playbackState PlaybackState change that is posted as an Event to the client. */ public static void dispatchPlaybackStateChanged(int playbackState) { Bundle news = new Bundle(); switch (playbackState) { case PlaybackState.STATE_PLAYING: mEventFacade.postEvent(EVENT_PLAY_RECEIVED, news); break; case PlaybackState.STATE_PAUSED: mEventFacade.postEvent(EVENT_PAUSE_RECEIVED, news); break; case PlaybackState.STATE_SKIPPING_TO_NEXT: mEventFacade.postEvent(EVENT_SKIP_NEXT_RECEIVED, news); break; case PlaybackState.STATE_SKIPPING_TO_PREVIOUS: mEventFacade.postEvent(EVENT_SKIP_PREV_RECEIVED, news); break; default: break; } } /******************************RPC APIS************************************************/ /** * Relevance - Phone and Car. * Sends the passthrough command through the currently active MediaController. * If there isn't one, look for the currently active sessions and just pick the first one, * just a fallback. * This function is generic enough to be used in either a Phone or the Car side, since * all this does is to pick the currently active Media Controller and sends a passthrough * command. In the test setup, this is used to mimic sending a passthrough command from * Car. */ @Rpc(description = "Simulate a passthrough command") public void bluetoothMediaPassthrough( @RpcParameter(name = "passthruCmd", description = "play/pause/skipFwd/skipBack") String passthruCmd) { Log.d(TAG + "Passthrough Cmd " + passthruCmd); if (mMediaController == null) { Log.i(TAG + " Media Controller not ready - Grabbing existing one"); ComponentName name = new ComponentName(mContext.getPackageName(), mSessionListener.getClass().getName()); List listMC = mSessionManager.getActiveSessions(null); if (listMC.size() > 0) { if (VDBG) { Log.d(TAG + " Num Sessions " + listMC.size()); for (int i = 0; i < listMC.size(); i++) { Log.d(TAG + "Active session : " + i + ((MediaController) (listMC.get( i))).getPackageName() + ((MediaController) (listMC.get( i))).getTag()); } } mMediaController = (MediaController) listMC.get(0); } else { Log.d(TAG + " No Active Media Session to grab"); return; } } switch (passthruCmd) { case CMD_MEDIA_PLAY: mMediaController.getTransportControls().play(); break; case CMD_MEDIA_PAUSE: mMediaController.getTransportControls().pause(); break; case CMD_MEDIA_SKIP_NEXT: mMediaController.getTransportControls().skipToNext(); break; case CMD_MEDIA_SKIP_PREV: mMediaController.getTransportControls().skipToPrevious(); break; default: Log.d(TAG + " Unsupported Passthrough Cmd"); break; } } /** * Relevance - Phone and Car. * Returns the currently playing media's metadata. * Can be queried on the car and the phone in the middle of a streaming session to * verify they are in sync. * * @return Currently playing Media's metadata */ @Rpc(description = "Gets the Metadata of currently playing Media") public Map bluetoothMediaGetCurrentMediaMetaData() { Map track = null; if (mMediaController == null) { Log.d(TAG + "MediaController Not set"); return track; } MediaMetadata metadata = mMediaController.getMetadata(); if (metadata == null) { Log.e("No Metadata available."); return track; } track = new HashMap<>(); track.put(MEDIA_KEY_TITLE, metadata.getString(MediaMetadata.METADATA_KEY_TITLE)); track.put(MEDIA_KEY_ALBUM, metadata.getString(MediaMetadata.METADATA_KEY_ALBUM)); track.put(MEDIA_KEY_ARTIST, metadata.getString(MediaMetadata.METADATA_KEY_ARTIST)); track.put(MEDIA_KEY_DURATION, String.valueOf(metadata.getLong(MediaMetadata.METADATA_KEY_DURATION))); track.put(MEDIA_KEY_NUM_TRACKS, String.valueOf(metadata.getLong(MediaMetadata.METADATA_KEY_NUM_TRACKS))); return track; } /** * Relevance - Phone and Car. * Returns the currently playing media's playback state. * Can be queried on the car and the phone in the middle of a streaming session to * verify they are in sync. * * @return Currently playing Media's playback state */ @Rpc(description = "Gets the state of current playback") public PlaybackState bluetoothMediaGetCurrentPlaybackState() throws Exception { if (mMediaController == null) { Log.e(TAG + "MediaController not set"); throw new Exception("MediaController not set"); } PlaybackState playbackState = mMediaController.getPlaybackState(); if (playbackState == null) { Log.d("No playback state available."); return null; } return playbackState; } /** * Relevance - Phone and Car * Returns the current active media sessions for the device. This is useful to see if a * Media Session we are interested in is currently active. * In the Bluetooth Media tests, this is indirectly used to determine if audio is being * played via BT. For ex., when the Car and Phone are connected via BT and audio is being * streamed, BluetoothMediaBrowserService will be active on the Car side. If the connection is * terminated in the middle, BluetoothMediaBrowserService will no longer be active on the * Carkitt, whereas BluetoothSL4AAudioSrcMBS will still be active. * * @return A list of names of the active media sessions */ @Rpc(description = "Get the current active Media Sessions") public List bluetoothMediaGetActiveMediaSessions() { List controllers = mSessionManager.getActiveSessions(null); List sessions = new ArrayList(); for (MediaController mc : controllers) { sessions.add(mc.getTag()); } return sessions; } /** * Relevance - Car Only * Called from the Carkitt to connect a MediaBrowser to the Bluetooth Audio App's * BluetoothMediaBrowserService. The callback on successful connection gives the handle to * the MediaController through which we can send media commands. */ @Rpc(description = "Connect a MediaBrowser to the BluetoothMediaBrowserService in the Carkitt") public void bluetoothMediaConnectToCarMBS() { ComponentName compName; // Create a MediaBrowser to connect to the BluetoothMediaBrowserService if (mBrowser == null) { compName = new ComponentName(BLUETOOTH_PKG_NAME, BROWSER_SERVICE_NAME); // Note - MediaBrowser connect needs to be done on the Main Thread's handler, // otherwise we never get the ServiceConnected callback. Runnable createAndConnectMediaBrowser = new Runnable() { @Override public void run() { mBrowser = new MediaBrowser(mContext, compName, mBrowserConnectionCallback, null); if (mBrowser != null) { Log.d(TAG + " Connecting to MBS"); mBrowser.connect(); } else { Log.d(TAG + " Failed to create a MediaBrowser"); } } }; Handler mainHandler = new Handler(mContext.getMainLooper()); mainHandler.post(createAndConnectMediaBrowser); } //mBrowser } /** * Relevance - Phone Only * Start the BluetoothSL4AAudioSrcMBS on the Phone so the media commands coming in * via Bluetooth AVRCP can be intercepted by the SL4A test */ @Rpc(description = "Start the BluetoothSL4AAudioSrcMBS on Phone.") public void bluetoothMediaPhoneSL4AMBSStart() { Log.d(TAG + "Starting BluetoothSL4AAudioSrcMBS"); // Start the Avrcp Media Browser service. Starting it sets it to active. Intent startIntent = new Intent(mContext, BluetoothSL4AAudioSrcMBS.class); mContext.startService(startIntent); } /** * Relevance - Phone Only * Stop the BluetoothSL4AAudioSrcMBS */ @Rpc(description = "Stop the BluetoothSL4AAudioSrcMBS running on Phone.") public void bluetoothMediaPhoneSL4AMBSStop() { Log.d(TAG + "Stopping BluetoothSL4AAudioSrcMBS"); // Stop the Avrcp Media Browser service. Intent stopIntent = new Intent(mContext, BluetoothSL4AAudioSrcMBS.class); mContext.stopService(stopIntent); } /** * Relevance - Phone only * This is used to simulate play/pause/skip media commands on the Phone directly, as against * receiving these commands via AVRCP from the Carkitt. * This function talks to the BluetoothSL4AAudioSrcMBS to simulate the media command. * An example test where this would be useful - Play music on Phone that is not connected * on bluetooth and connect in the middle to verify if music is steamed to the other end. * * @param command - Media command to simulate on the Phone */ @Rpc(description = "Media Commands on the Phone's BluetoothAvrcpMBS.") public void bluetoothMediaHandleMediaCommandOnPhone(String command) { BluetoothSL4AAudioSrcMBS mbs = BluetoothSL4AAudioSrcMBS.getAvrcpMediaBrowserService(); if (mbs != null) { mbs.handleMediaCommand(command); } else { Log.e(TAG + " No BluetoothSL4AAudioSrcMBS running on the device"); } } @Override public void shutdown() { setCurrentMediaController(null); } }