A2DP sink audio focus
Resolved a bug where BT media fails to pause and subsequently resume during
a transient audio focus loss as is seen during voice recognition.
Resolved a bug where BT media may not resume after a phone call due to an
audio focus race condition.
Resolved a bug where BT phone notifications could interrupt and disrupt
an ongoing media session such as radio or local media player.
Bug: 34853256
Bug: 36529639
Bug: 37288772
Test: runtest bluetooth -c
com.android.bluetooth.a2dpsink.A2dpSinkStreamHandlerTest
Change-Id: I5261d24fd7bbe49bf61c48fdf2c9ae86934dcd3d
diff --git a/src/com/android/bluetooth/a2dpsink/A2dpSinkStateMachine.java b/src/com/android/bluetooth/a2dpsink/A2dpSinkStateMachine.java
index 5102369..02f8f1d 100644
--- a/src/com/android/bluetooth/a2dpsink/A2dpSinkStateMachine.java
+++ b/src/com/android/bluetooth/a2dpsink/A2dpSinkStateMachine.java
@@ -58,7 +58,7 @@
import java.util.HashMap;
import java.util.Set;
-final class A2dpSinkStateMachine extends StateMachine {
+public class A2dpSinkStateMachine extends StateMachine {
private static final boolean DBG = false;
static final int CONNECT = 1;
@@ -573,15 +573,20 @@
break;
case EVENT_AVRCP_CT_PLAY:
+ mStreaming.obtainMessage(A2dpSinkStreamHandler.SNK_PLAY).sendToTarget();
+ break;
+
case EVENT_AVRCP_TG_PLAY:
- mStreaming.obtainMessage(A2dpSinkStreamHandler.ACT_PLAY).sendToTarget();
+ mStreaming.obtainMessage(A2dpSinkStreamHandler.SRC_PLAY).sendToTarget();
break;
case EVENT_AVRCP_CT_PAUSE:
- case EVENT_AVRCP_TG_PAUSE:
- mStreaming.obtainMessage(A2dpSinkStreamHandler.ACT_PAUSE).sendToTarget();
+ mStreaming.obtainMessage(A2dpSinkStreamHandler.SNK_PAUSE).sendToTarget();
break;
+ case EVENT_AVRCP_TG_PAUSE:
+ mStreaming.obtainMessage(A2dpSinkStreamHandler.SRC_PAUSE).sendToTarget();
+ break;
default:
return NOT_HANDLED;
diff --git a/src/com/android/bluetooth/a2dpsink/A2dpSinkStreamHandler.java b/src/com/android/bluetooth/a2dpsink/A2dpSinkStreamHandler.java
index deb0563..33fe1ec 100644
--- a/src/com/android/bluetooth/a2dpsink/A2dpSinkStreamHandler.java
+++ b/src/com/android/bluetooth/a2dpsink/A2dpSinkStreamHandler.java
@@ -37,9 +37,7 @@
*
* Note: There are several different audio tracks that a connected phone may like to transmit over
* the A2DP stream including Music, Navigation, Assistant, and Notifications. Music is the only
- * track that is almost always accompanied with an AVRCP play/pause command. The following handler
- * is configurable at compile time through the PLAY_WITHOUT_AVRCP_COMMAND flag to allow all of these
- * audio tracks to be played trough without an explicit play command.
+ * track that is almost always accompanied with an AVRCP play/pause command.
*
* Streaming is initiated by either an explicit play command from user interaction or audio coming
* from the phone. Streaming is terminated when either the user pauses the audio, the audio stream
@@ -47,44 +45,37 @@
* a change to audio focus playback may be temporarily paused and then resumed when focus is
* restored.
*/
-final class A2dpSinkStreamHandler extends Handler {
+public class A2dpSinkStreamHandler extends Handler {
private static final boolean DBG = false;
private static final String TAG = "A2dpSinkStreamHandler";
// Configuration Variables
private static final int DEFAULT_DUCK_PERCENT = 25;
- // Allows any audio to stream from phone without requiring AVRCP play command,
- // this lets navigation and other non music streams through.
- private static final boolean PLAY_WITHOUT_AVRCP_COMMAND = true;
// Incoming events.
- public static final int SRC_STR_START = 0;
- public static final int SRC_STR_STOP = 1;
- public static final int ACT_PLAY = 2;
- public static final int ACT_PAUSE = 3;
- public static final int DISCONNECT = 4;
- public static final int UPGRADE_FOCUS = 5;
- public static final int AUDIO_FOCUS_CHANGE = 7;
+ public static final int SRC_STR_START = 0; // Audio stream from remote device started
+ public static final int SRC_STR_STOP = 1; // Audio stream from remote device stopped
+ public static final int SNK_PLAY = 2; // Play command was generated from local device
+ public static final int SNK_PAUSE = 3; // Pause command was generated from local device
+ public static final int SRC_PLAY = 4; // Play command was generated from remote device
+ public static final int SRC_PAUSE = 5; // Pause command was generated from remote device
+ public static final int DISCONNECT = 6; // Remote device was disconnected
+ public static final int AUDIO_FOCUS_CHANGE = 7; // Audio focus callback with associated change
// Used to indicate focus lost
private static final int STATE_FOCUS_LOST = 0;
// Used to inform bluedroid that focus is granted
private static final int STATE_FOCUS_GRANTED = 1;
- // Timeout in milliseconds before upgrading a transient audio focus to full focus;
- // This allows notifications and other intermittent sounds from impacting other sources.
- private static final int TRANSIENT_FOCUS_DELAY = 10000; // 10 seconds
// Private variables.
private A2dpSinkStateMachine mA2dpSinkSm;
private Context mContext;
private AudioManager mAudioManager;
- private AudioAttributes mStreamAttributes;
- // Keep track if play was requested
- private boolean playRequested = false;
// Keep track if the remote device is providing audio
- private boolean streamAvailable = false;
+ private boolean mStreamAvailable = false;
+ private boolean mSentPause = false;
// Keep track of the relevant audio focus (None, Transient, Gain)
- private int audioFocus = AudioManager.AUDIOFOCUS_NONE;
+ private int mAudioFocus = AudioManager.AUDIOFOCUS_NONE;
// Focus changes when we are currently holding focus.
private OnAudioFocusChangeListener mAudioFocusListener = new OnAudioFocusChangeListener() {
@@ -101,80 +92,76 @@
mA2dpSinkSm = a2dpSinkSm;
mContext = context;
mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
- mStreamAttributes = new AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_MEDIA)
- .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
- .build();
}
@Override
public void handleMessage(Message message) {
if (DBG) {
Log.d(TAG, " process message: " + message.what);
- Log.d(TAG, " audioFocus = " + audioFocus + " playRequested = " + playRequested);
+ Log.d(TAG, " audioFocus = " + mAudioFocus);
}
switch (message.what) {
case SRC_STR_START:
- streamAvailable = true;
- if ((playRequested || PLAY_WITHOUT_AVRCP_COMMAND)
- && audioFocus == AudioManager.AUDIOFOCUS_NONE) {
- requestAudioFocus();
+ // Audio stream has started, stop it if we don't have focus.
+ mStreamAvailable = true;
+ if (mAudioFocus == AudioManager.AUDIOFOCUS_NONE) {
+ sendAvrcpPause();
+ } else {
+ startAvrcpUpdates();
}
break;
case SRC_STR_STOP:
- streamAvailable = false;
- if (audioFocus != AudioManager.AUDIOFOCUS_NONE) {
- abandonAudioFocus();
- }
+ // Audio stream has stopped, maintain focus but stop avrcp updates.
+ mStreamAvailable = false;
+ stopAvrcpUpdates();
break;
- case ACT_PLAY:
- playRequested = true;
- startAvrcpUpdates();
- if (streamAvailable && audioFocus == AudioManager.AUDIOFOCUS_NONE) {
+ case SNK_PLAY:
+ // Local play command, gain focus and start avrcp updates.
+ if (mAudioFocus == AudioManager.AUDIOFOCUS_NONE) {
requestAudioFocus();
}
+ startAvrcpUpdates();
break;
- case ACT_PAUSE:
- playRequested = false;
+ case SNK_PAUSE:
+ // Local pause command, maintain focus but stop avrcp updates.
+ stopAvrcpUpdates();
+ break;
+
+ case SRC_PLAY:
+ // Remote play command, if we have audio focus update avrcp, otherwise send pause.
+ if (mAudioFocus == AudioManager.AUDIOFOCUS_NONE) {
+ sendAvrcpPause();
+ } else {
+ startAvrcpUpdates();
+ }
+ break;
+
+ case SRC_PAUSE:
+ // Remote pause command, stop avrcp updates.
stopAvrcpUpdates();
break;
case DISCONNECT:
- playRequested = false;
+ // Remote device has disconnected, restore everything to default state.
sendAvrcpPause();
stopAvrcpUpdates();
- stopFluorideStreaming();
abandonAudioFocus();
- break;
-
- case UPGRADE_FOCUS:
- upgradeAudioFocus();
+ mSentPause = false;
break;
case AUDIO_FOCUS_CHANGE:
// message.obj is the newly granted audio focus.
switch ((int) message.obj) {
- case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT:
- setFluorideAudioTrackGain(1.0f);
- sendMessageDelayed(obtainMessage(UPGRADE_FOCUS), TRANSIENT_FOCUS_DELAY);
- // Begin playing audio
- if (audioFocus == AudioManager.AUDIOFOCUS_NONE) {
- audioFocus = (int) message.obj;
- startAvrcpUpdates();
- startFluorideStreaming();
- }
- break;
-
case AudioManager.AUDIOFOCUS_GAIN:
- setFluorideAudioTrackGain(1.0f);
- // Begin playing audio
- if (audioFocus == AudioManager.AUDIOFOCUS_NONE) {
- audioFocus = (int) message.obj;
- startAvrcpUpdates();
- startFluorideStreaming();
+ // Begin playing audio, if we paused the remote, send a play now.
+ startAvrcpUpdates();
+ startFluorideStreaming();
+ if (mSentPause) {
+ sendAvrcpPlay();
+ mSentPause = false;
}
break;
@@ -191,19 +178,24 @@
Log.d(TAG, "Setting reduce gain on transient loss gain=" + duckRatio);
}
setFluorideAudioTrackGain(duckRatio);
- removeMessages(UPGRADE_FOCUS);
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
+ // Temporary loss of focus, if we are actively streaming pause the remote
+ // and make sure we resume playback when we regain focus.
+ if (mStreamAvailable) {
+ sendAvrcpPause();
+ mSentPause = true;
+ }
stopFluorideStreaming();
- removeMessages(UPGRADE_FOCUS);
break;
case AudioManager.AUDIOFOCUS_LOSS:
+ // Permanent loss of focus probably due to another audio app, abandon focus
+ // and stop playback.
+ mAudioFocus = AudioManager.AUDIOFOCUS_NONE;
abandonAudioFocus();
sendAvrcpPause();
- stopAvrcpUpdates();
- stopFluorideStreaming();
break;
}
break;
@@ -217,32 +209,22 @@
* Utility functions.
*/
private int requestAudioFocus() {
- int focusRequestStatus = mAudioManager.requestAudioFocus(mAudioFocusListener,
- mStreamAttributes, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
- AudioManager.AUDIOFOCUS_FLAG_DELAY_OK);
+ int focusRequestStatus = mAudioManager.requestAudioFocus(
+ mAudioFocusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
// If the request is granted begin streaming immediately and schedule an upgrade.
if (focusRequestStatus == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
startAvrcpUpdates();
- setFluorideAudioTrackGain(1.0f);
startFluorideStreaming();
- audioFocus = AudioManager.AUDIOFOCUS_GAIN_TRANSIENT;
- sendMessageDelayed(obtainMessage(UPGRADE_FOCUS), TRANSIENT_FOCUS_DELAY);
+ mAudioFocus = AudioManager.AUDIOFOCUS_GAIN;
}
return focusRequestStatus;
}
- private boolean upgradeAudioFocus() {
- return (mAudioManager.requestAudioFocus(mAudioFocusListener, AudioManager.STREAM_MUSIC,
- AudioManager.AUDIOFOCUS_GAIN)
- == AudioManager.AUDIOFOCUS_REQUEST_GRANTED);
- }
private void abandonAudioFocus() {
- removeMessages(UPGRADE_FOCUS);
- stopAvrcpUpdates();
stopFluorideStreaming();
mAudioManager.abandonAudioFocus(mAudioFocusListener);
- audioFocus = AudioManager.AUDIOFOCUS_NONE;
+ mAudioFocus = AudioManager.AUDIOFOCUS_NONE;
}
private void startFluorideStreaming() {
@@ -307,4 +289,26 @@
Log.e(TAG, "Passthrough not sent, connection un-available.");
}
}
+
+ private void sendAvrcpPlay() {
+ // Since AVRCP gets started after A2DP we may need to request it later in cycle.
+ AvrcpControllerService avrcpService = AvrcpControllerService.getAvrcpControllerService();
+
+ if (DBG) {
+ Log.d(TAG, "sendAvrcpPlay");
+ }
+ if (avrcpService != null && avrcpService.getConnectedDevices().size() == 1) {
+ if (DBG) {
+ Log.d(TAG, "Playing AVRCP.");
+ }
+ avrcpService.sendPassThroughCmd(avrcpService.getConnectedDevices().get(0),
+ AvrcpControllerService.PASS_THRU_CMD_ID_PLAY,
+ AvrcpControllerService.KEY_STATE_PRESSED);
+ avrcpService.sendPassThroughCmd(avrcpService.getConnectedDevices().get(0),
+ AvrcpControllerService.PASS_THRU_CMD_ID_PLAY,
+ AvrcpControllerService.KEY_STATE_RELEASED);
+ } else {
+ Log.e(TAG, "Passthrough not sent, connection un-available.");
+ }
+ }
}
diff --git a/tests/src/com/android/bluetooth/a2dpsink/A2dpSinkStreamHandlerTest.java b/tests/src/com/android/bluetooth/a2dpsink/A2dpSinkStreamHandlerTest.java
new file mode 100644
index 0000000..2f18960
--- /dev/null
+++ b/tests/src/com/android/bluetooth/a2dpsink/A2dpSinkStreamHandlerTest.java
@@ -0,0 +1,189 @@
+/*
+ * 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.android.bluetooth.a2dpsink;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyFloat;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.media.AudioManager;
+import android.media.AudioManager.OnAudioFocusChangeListener;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.test.AndroidTestCase;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.runners.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class A2dpSinkStreamHandlerTest extends AndroidTestCase {
+ static final int DUCK_PERCENT = 75;
+ private HandlerThread mHandlerThread;
+ A2dpSinkStreamHandler streamHandler;
+ ArgumentCaptor<OnAudioFocusChangeListener> audioFocusChangeListenerArgumentCaptor;
+
+ @Mock Context mockContext;
+
+ @Mock A2dpSinkStateMachine mockA2dpSink;
+
+ @Mock AudioManager mockAudioManager;
+
+ @Mock Resources mockResources;
+
+ @Before
+ public void setUp() {
+ // Mock the looper
+ if (Looper.myLooper() == null) {
+ Looper.prepare();
+ }
+
+ mHandlerThread = new HandlerThread("A2dpSinkStreamHandlerTest");
+ mHandlerThread.start();
+
+ audioFocusChangeListenerArgumentCaptor =
+ ArgumentCaptor.forClass(OnAudioFocusChangeListener.class);
+ when(mockContext.getSystemService(Context.AUDIO_SERVICE)).thenReturn(mockAudioManager);
+ when(mockContext.getResources()).thenReturn(mockResources);
+ when(mockResources.getInteger(anyInt())).thenReturn(DUCK_PERCENT);
+ when(mockAudioManager.requestAudioFocus(audioFocusChangeListenerArgumentCaptor.capture(),
+ eq(AudioManager.STREAM_MUSIC), eq(AudioManager.AUDIOFOCUS_GAIN)))
+ .thenReturn(AudioManager.AUDIOFOCUS_REQUEST_GRANTED);
+ when(mockAudioManager.abandonAudioFocus(any())).thenReturn(AudioManager.AUDIOFOCUS_GAIN);
+ doNothing().when(mockA2dpSink).informAudioTrackGainNative(anyFloat());
+ when(mockContext.getMainLooper()).thenReturn(mHandlerThread.getLooper());
+
+ streamHandler = spy(new A2dpSinkStreamHandler(mockA2dpSink, mockContext));
+ }
+
+ @Test
+ public void testSrcStart() {
+ // Stream started without local play, expect no change in streaming.
+ streamHandler.handleMessage(
+ streamHandler.obtainMessage(A2dpSinkStreamHandler.SRC_STR_START));
+ verify(mockAudioManager, times(0)).requestAudioFocus(any(), anyInt(), anyInt());
+ verify(mockA2dpSink, times(0)).informAudioFocusStateNative(1);
+ verify(mockA2dpSink, times(0)).informAudioTrackGainNative(1.0f);
+ }
+
+ @Test
+ public void testSrcStop() {
+ // Stream stopped without local play, expect no change in streaming.
+ streamHandler.handleMessage(
+ streamHandler.obtainMessage(A2dpSinkStreamHandler.SRC_STR_STOP));
+ verify(mockAudioManager, times(0)).requestAudioFocus(any(), anyInt(), anyInt());
+ verify(mockA2dpSink, times(0)).informAudioFocusStateNative(1);
+ verify(mockA2dpSink, times(0)).informAudioTrackGainNative(1.0f);
+ }
+
+ @Test
+ public void testSnkPlay() {
+ // Play was pressed locally, expect streaming to start.
+ streamHandler.handleMessage(streamHandler.obtainMessage(A2dpSinkStreamHandler.SNK_PLAY));
+ verify(mockAudioManager, times(1)).requestAudioFocus(any(), anyInt(), anyInt());
+ verify(mockA2dpSink, times(1)).informAudioFocusStateNative(1);
+ verify(mockA2dpSink, times(1)).informAudioTrackGainNative(1.0f);
+ }
+
+ @Test
+ public void testSnkPause() {
+ // Pause was pressed locally, expect streaming to stop.
+ streamHandler.handleMessage(streamHandler.obtainMessage(A2dpSinkStreamHandler.SNK_PAUSE));
+ verify(mockAudioManager, times(0)).requestAudioFocus(any(), anyInt(), anyInt());
+ verify(mockA2dpSink, times(0)).informAudioFocusStateNative(1);
+ verify(mockA2dpSink, times(0)).informAudioTrackGainNative(1.0f);
+ }
+
+ @Test
+ public void testDisconnect() {
+ // Remote device was disconnected, expect streaming to stop.
+ testSnkPlay();
+ streamHandler.handleMessage(streamHandler.obtainMessage(A2dpSinkStreamHandler.DISCONNECT));
+ verify(mockAudioManager, times(1)).abandonAudioFocus(any());
+ verify(mockA2dpSink, times(1)).informAudioFocusStateNative(0);
+ }
+
+ @Test
+ public void testSrcPlay() {
+ // Play was pressed remotely, expect no streaming due to lack of audio focus.
+ streamHandler.handleMessage(streamHandler.obtainMessage(A2dpSinkStreamHandler.SRC_PLAY));
+ verify(mockAudioManager, times(0)).requestAudioFocus(any(), anyInt(), anyInt());
+ verify(mockA2dpSink, times(0)).informAudioFocusStateNative(1);
+ verify(mockA2dpSink, times(0)).informAudioTrackGainNative(1.0f);
+ }
+
+ @Test
+ public void testSrcPause() {
+ // Play was pressed locally, expect streaming to start.
+ streamHandler.handleMessage(streamHandler.obtainMessage(A2dpSinkStreamHandler.SRC_PLAY));
+ verify(mockAudioManager, times(0)).requestAudioFocus(any(), anyInt(), anyInt());
+ verify(mockA2dpSink, times(0)).informAudioFocusStateNative(1);
+ verify(mockA2dpSink, times(0)).informAudioTrackGainNative(1.0f);
+ }
+
+ @Test
+ public void testFocusGain() {
+ // Focus was gained, expect streaming to resume.
+ testSnkPlay();
+ streamHandler.handleMessage(streamHandler.obtainMessage(
+ A2dpSinkStreamHandler.AUDIO_FOCUS_CHANGE, AudioManager.AUDIOFOCUS_GAIN));
+ verify(mockAudioManager, times(1)).requestAudioFocus(any(), anyInt(), anyInt());
+ verify(mockA2dpSink, times(2)).informAudioFocusStateNative(1);
+ verify(mockA2dpSink, times(2)).informAudioTrackGainNative(1.0f);
+ }
+
+ @Test
+ public void testFocusTransientMayDuck() {
+ // TransientMayDuck focus was gained, expect audio stream to duck.
+ testSnkPlay();
+ streamHandler.handleMessage(
+ streamHandler.obtainMessage(A2dpSinkStreamHandler.AUDIO_FOCUS_CHANGE,
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK));
+ verify(mockA2dpSink, times(1)).informAudioTrackGainNative(DUCK_PERCENT / 100.0f);
+ }
+
+ @Test
+ public void testFocusLostTransient() {
+ // Focus was lost transiently, expect streaming to stop.
+ testSnkPlay();
+ streamHandler.handleMessage(streamHandler.obtainMessage(
+ A2dpSinkStreamHandler.AUDIO_FOCUS_CHANGE, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT));
+ verify(mockAudioManager, times(0)).abandonAudioFocus(any());
+ verify(mockA2dpSink, times(1)).informAudioFocusStateNative(0);
+ }
+
+ @Test
+ public void testFocusLost() {
+ // Focus was lost permanently, expect streaming to stop.
+ testSnkPlay();
+ streamHandler.handleMessage(streamHandler.obtainMessage(
+ A2dpSinkStreamHandler.AUDIO_FOCUS_CHANGE, AudioManager.AUDIOFOCUS_LOSS));
+ verify(mockAudioManager, times(1)).abandonAudioFocus(any());
+ verify(mockA2dpSink, times(1)).informAudioFocusStateNative(0);
+ }
+}