1 package org.robolectric.shadows; 2 3 import static android.os.Build.VERSION_CODES.P; 4 import static android.os.Build.VERSION_CODES.S; 5 import static java.util.stream.Collectors.toCollection; 6 7 import android.bluetooth.BluetoothDevice; 8 import android.bluetooth.BluetoothHeadset; 9 import android.bluetooth.BluetoothProfile; 10 import android.content.Intent; 11 import com.google.common.collect.ImmutableList; 12 import com.google.common.collect.ImmutableSet; 13 import com.google.common.primitives.Ints; 14 import java.util.ArrayList; 15 import java.util.HashMap; 16 import java.util.List; 17 import java.util.Map; 18 import java.util.Map.Entry; 19 import java.util.Objects; 20 import javax.annotation.Nullable; 21 import javax.annotation.concurrent.NotThreadSafe; 22 import org.robolectric.RuntimeEnvironment; 23 import org.robolectric.annotation.Implementation; 24 import org.robolectric.annotation.Implements; 25 26 /** Shadow for {@link BluetoothHeadset} */ 27 @NotThreadSafe 28 @Implements(value = BluetoothHeadset.class) 29 public class ShadowBluetoothHeadset { 30 31 private final Map<BluetoothDevice, Integer> bluetoothDevices = new HashMap<>(); 32 private boolean allowsSendVendorSpecificResultCode = true; 33 private BluetoothDevice activeBluetoothDevice; 34 private boolean isVoiceRecognitionSupported = true; 35 36 /** 37 * Overrides behavior of {@link getConnectedDevices}. Returns list of devices that is set up by 38 * call(s) to {@link ShadowBluetoothHeadset#addConnectedDevice} or {@link connect}. Returns an 39 * empty list by default. 40 */ 41 @Implementation getConnectedDevices()42 protected List<BluetoothDevice> getConnectedDevices() { 43 return bluetoothDevices.entrySet().stream() 44 .filter(entry -> entry.getValue() == BluetoothProfile.STATE_CONNECTED) 45 .map(Entry::getKey) 46 .collect(toCollection(ArrayList::new)); 47 } 48 49 /** Adds the given BluetoothDevice to the shadow's list of "connected devices" */ addConnectedDevice(BluetoothDevice device)50 public void addConnectedDevice(BluetoothDevice device) { 51 addDevice(device, BluetoothProfile.STATE_CONNECTED); 52 } 53 54 /** 55 * Adds the provided BluetoothDevice to the shadow profile's device list with an associated 56 * connectionState. The provided connection state will be returned by {@link 57 * ShadowBluetoothHeadset#getConnectionState}. 58 */ addDevice(BluetoothDevice bluetoothDevice, int connectionState)59 public void addDevice(BluetoothDevice bluetoothDevice, int connectionState) { 60 bluetoothDevices.put(bluetoothDevice, connectionState); 61 } 62 63 /** Remove the given BluetoothDevice from the shadow profile's device list */ removeDevice(BluetoothDevice bluetoothDevice)64 public void removeDevice(BluetoothDevice bluetoothDevice) { 65 bluetoothDevices.remove(bluetoothDevice); 66 } 67 68 /** 69 * Overrides behavior of {@link getConnectionState}. 70 * 71 * @return {@code BluetoothProfile.STATE_CONNECTED} if the given device has been previously added 72 * by a call to {@link ShadowBluetoothHeadset#addConnectedDevice} or {@link connect}, and 73 * {@code BluetoothProfile.STATE_DISCONNECTED} otherwise. 74 */ 75 @Implementation getConnectionState(BluetoothDevice device)76 protected int getConnectionState(BluetoothDevice device) { 77 return bluetoothDevices.getOrDefault(device, BluetoothProfile.STATE_DISCONNECTED); 78 } 79 80 @Implementation getDevicesMatchingConnectionStates(int[] states)81 protected List<BluetoothDevice> getDevicesMatchingConnectionStates(int[] states) { 82 ImmutableSet<Integer> statesSet = ImmutableSet.copyOf(Ints.asList(states)); 83 List<BluetoothDevice> matchingDevices = new ArrayList<>(); 84 for (Map.Entry<BluetoothDevice, Integer> entry : bluetoothDevices.entrySet()) { 85 if (statesSet.contains(entry.getValue())) { 86 matchingDevices.add(entry.getKey()); 87 } 88 } 89 return ImmutableList.copyOf(matchingDevices); 90 } 91 92 /** 93 * Overrides behavior of {@link connect}. Returns {@code true} and adds {@code device} to the 94 * shadow profile's connected device list if {@code device} is currently disconnected, and returns 95 * {@code false} otherwise. 96 */ 97 @Implementation connect(BluetoothDevice device)98 protected boolean connect(BluetoothDevice device) { 99 if (getConnectedDevices().contains(device)) { 100 return false; 101 } 102 addConnectedDevice(device); 103 return true; 104 } 105 106 /** 107 * Overrides behavior of {@link disconnect}. Returns {@code true} and removes {@code device} from 108 * the shadow profile's connected device list if {@code device} is currently connected, and 109 * returns {@code false} otherwise. 110 */ 111 @Implementation disconnect(BluetoothDevice device)112 protected boolean disconnect(BluetoothDevice device) { 113 if (!getConnectedDevices().contains(device)) { 114 return false; 115 } 116 removeDevice(device); 117 return true; 118 } 119 120 /** 121 * Overrides behavior of {@link startVoiceRecognition}. Returns false if 'bluetoothDevice' is null 122 * or voice recognition is already started. Users can listen to {@link 123 * ACTION_AUDIO_STATE_CHANGED}. If this function returns true, this intent will be broadcasted 124 * once with {@link BluetoothProfile.EXTRA_STATE} set to {@link STATE_AUDIO_CONNECTING} and once 125 * set to {@link STATE_AUDIO_CONNECTED}. 126 */ 127 @Implementation startVoiceRecognition(BluetoothDevice bluetoothDevice)128 protected boolean startVoiceRecognition(BluetoothDevice bluetoothDevice) { 129 if (bluetoothDevice == null || !getConnectedDevices().contains(bluetoothDevice)) { 130 return false; 131 } 132 if (activeBluetoothDevice != null) { 133 stopVoiceRecognition(activeBluetoothDevice); 134 return false; 135 } 136 sendAudioStateChangedBroadcast(BluetoothHeadset.STATE_AUDIO_CONNECTING, bluetoothDevice); 137 sendAudioStateChangedBroadcast(BluetoothHeadset.STATE_AUDIO_CONNECTED, bluetoothDevice); 138 139 activeBluetoothDevice = bluetoothDevice; 140 return true; 141 } 142 143 /** 144 * Overrides the behavior of {@link stopVoiceRecognition}. Returns false if voice recognition was 145 * not started or voice recognition has already ended on this headset. If this function returns 146 * true, {@link ACTION_AUDIO_STATE_CHANGED} intent is broadcasted with {@link 147 * BluetoothProfile.EXTRA_STATE} set to {@link STATE_DISCONNECTED}. 148 */ 149 @Implementation stopVoiceRecognition(BluetoothDevice bluetoothDevice)150 protected boolean stopVoiceRecognition(BluetoothDevice bluetoothDevice) { 151 boolean isDeviceActive = isDeviceActive(bluetoothDevice); 152 activeBluetoothDevice = null; 153 if (isDeviceActive) { 154 sendAudioStateChangedBroadcast(BluetoothHeadset.STATE_AUDIO_DISCONNECTED, bluetoothDevice); 155 } 156 return isDeviceActive; 157 } 158 159 @Implementation isAudioConnected(BluetoothDevice bluetoothDevice)160 protected boolean isAudioConnected(BluetoothDevice bluetoothDevice) { 161 return isDeviceActive(bluetoothDevice); 162 } 163 164 /** 165 * Overrides behavior of {@link sendVendorSpecificResultCode}. 166 * 167 * @return 'true' only if the given device has been previously added by a call to {@link 168 * ShadowBluetoothHeadset#addConnectedDevice} or {@link connect}, and {@link 169 * ShadowBluetoothHeadset#setAllowsSendVendorSpecificResultCode} has not been called with 170 * 'false' argument. 171 * @throws IllegalArgumentException if 'command' argument is null, per Android API 172 */ 173 @Implementation sendVendorSpecificResultCode( BluetoothDevice device, String command, String arg)174 protected boolean sendVendorSpecificResultCode( 175 BluetoothDevice device, String command, String arg) { 176 if (command == null) { 177 throw new IllegalArgumentException("Command cannot be null"); 178 } 179 return allowsSendVendorSpecificResultCode && getConnectedDevices().contains(device); 180 } 181 182 @Nullable 183 @Implementation(minSdk = P) getActiveDevice()184 protected BluetoothDevice getActiveDevice() { 185 return activeBluetoothDevice; 186 } 187 188 @Implementation(minSdk = P) setActiveDevice(@ullable BluetoothDevice bluetoothDevice)189 protected boolean setActiveDevice(@Nullable BluetoothDevice bluetoothDevice) { 190 activeBluetoothDevice = bluetoothDevice; 191 Intent intent = new Intent(BluetoothHeadset.ACTION_ACTIVE_DEVICE_CHANGED); 192 intent.putExtra(BluetoothDevice.EXTRA_DEVICE, activeBluetoothDevice); 193 RuntimeEnvironment.getApplication().sendBroadcast(intent); 194 return true; 195 } 196 197 /** 198 * Sets whether the headset supports voice recognition. 199 * 200 * <p>By default voice recognition is supported. 201 * 202 * @see #isVoiceRecognitionSupported(BluetoothDevice) 203 */ setVoiceRecognitionSupported(boolean supported)204 public void setVoiceRecognitionSupported(boolean supported) { 205 isVoiceRecognitionSupported = supported; 206 } 207 208 /** 209 * Checks whether the headset supports voice recognition. 210 * 211 * @see #setVoiceRecognitionSupported(boolean) 212 */ 213 @Implementation(minSdk = S) isVoiceRecognitionSupported(BluetoothDevice device)214 protected boolean isVoiceRecognitionSupported(BluetoothDevice device) { 215 return isVoiceRecognitionSupported; 216 } 217 218 /** 219 * Affects the behavior of {@link BluetoothHeadset#sendVendorSpecificResultCode} 220 * 221 * @param allowsSendVendorSpecificResultCode can be set to 'false' to simulate the situation where 222 * the system is unable to send vendor-specific result codes to a device 223 */ setAllowsSendVendorSpecificResultCode(boolean allowsSendVendorSpecificResultCode)224 public void setAllowsSendVendorSpecificResultCode(boolean allowsSendVendorSpecificResultCode) { 225 this.allowsSendVendorSpecificResultCode = allowsSendVendorSpecificResultCode; 226 } 227 isDeviceActive(BluetoothDevice bluetoothDevice)228 private boolean isDeviceActive(BluetoothDevice bluetoothDevice) { 229 return Objects.equals(activeBluetoothDevice, bluetoothDevice); 230 } 231 sendAudioStateChangedBroadcast( int bluetoothProfileExtraState, BluetoothDevice bluetoothDevice)232 private static void sendAudioStateChangedBroadcast( 233 int bluetoothProfileExtraState, BluetoothDevice bluetoothDevice) { 234 Intent connectedIntent = 235 new Intent(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) 236 .putExtra(BluetoothProfile.EXTRA_STATE, bluetoothProfileExtraState) 237 .putExtra(BluetoothDevice.EXTRA_DEVICE, bluetoothDevice); 238 239 RuntimeEnvironment.getApplication().sendBroadcast(connectedIntent); 240 } 241 } 242