1 /* 2 * Copyright (C) 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 package com.android.settingslib.bluetooth; 17 18 import android.bluetooth.BluetoothCsipSetCoordinator; 19 import android.bluetooth.BluetoothHapClient; 20 import android.bluetooth.BluetoothHearingAid; 21 import android.bluetooth.BluetoothLeAudio; 22 import android.bluetooth.BluetoothProfile; 23 import android.bluetooth.BluetoothUuid; 24 import android.bluetooth.le.ScanFilter; 25 import android.content.ContentResolver; 26 import android.content.Context; 27 import android.media.AudioDeviceAttributes; 28 import android.media.audiopolicy.AudioProductStrategy; 29 import android.os.ParcelUuid; 30 import android.provider.Settings; 31 import android.util.FeatureFlagUtils; 32 import android.util.Log; 33 34 import com.android.internal.annotations.VisibleForTesting; 35 36 import java.util.HashSet; 37 import java.util.List; 38 import java.util.Set; 39 40 /** 41 * HearingAidDeviceManager manages the set of remote HearingAid(ASHA) Bluetooth devices. 42 */ 43 public class HearingAidDeviceManager { 44 private static final String TAG = "HearingAidDeviceManager"; 45 private static final boolean DEBUG = BluetoothUtils.D; 46 47 private final ContentResolver mContentResolver; 48 private final Context mContext; 49 private final LocalBluetoothManager mBtManager; 50 private final List<CachedBluetoothDevice> mCachedDevices; 51 private final HearingAidAudioRoutingHelper mRoutingHelper; HearingAidDeviceManager(Context context, LocalBluetoothManager localBtManager, List<CachedBluetoothDevice> CachedDevices)52 HearingAidDeviceManager(Context context, LocalBluetoothManager localBtManager, 53 List<CachedBluetoothDevice> CachedDevices) { 54 mContext = context; 55 mContentResolver = context.getContentResolver(); 56 mBtManager = localBtManager; 57 mCachedDevices = CachedDevices; 58 mRoutingHelper = new HearingAidAudioRoutingHelper(context); 59 } 60 61 @VisibleForTesting HearingAidDeviceManager(Context context, LocalBluetoothManager localBtManager, List<CachedBluetoothDevice> cachedDevices, HearingAidAudioRoutingHelper routingHelper)62 HearingAidDeviceManager(Context context, LocalBluetoothManager localBtManager, 63 List<CachedBluetoothDevice> cachedDevices, HearingAidAudioRoutingHelper routingHelper) { 64 mContext = context; 65 mContentResolver = context.getContentResolver(); 66 mBtManager = localBtManager; 67 mCachedDevices = cachedDevices; 68 mRoutingHelper = routingHelper; 69 } 70 initHearingAidDeviceIfNeeded(CachedBluetoothDevice newDevice, List<ScanFilter> leScanFilters)71 void initHearingAidDeviceIfNeeded(CachedBluetoothDevice newDevice, 72 List<ScanFilter> leScanFilters) { 73 HearingAidInfo info = generateHearingAidInfo(newDevice); 74 if (info != null) { 75 newDevice.setHearingAidInfo(info); 76 } else if (leScanFilters != null && !newDevice.isHearingAidDevice()) { 77 // If the device is added with hearing aid scan filter during pairing, set an empty 78 // hearing aid info to indicate it's a hearing aid device. The info will be updated 79 // when corresponding profiles connected. 80 for (ScanFilter leScanFilter: leScanFilters) { 81 final ParcelUuid serviceUuid = leScanFilter.getServiceUuid(); 82 final ParcelUuid serviceDataUuid = leScanFilter.getServiceDataUuid(); 83 if (BluetoothUuid.HEARING_AID.equals(serviceUuid) 84 || BluetoothUuid.HAS.equals(serviceUuid) 85 || BluetoothUuid.HEARING_AID.equals(serviceDataUuid) 86 || BluetoothUuid.HAS.equals(serviceDataUuid)) { 87 newDevice.setHearingAidInfo(new HearingAidInfo.Builder().build()); 88 break; 89 } 90 } 91 } 92 } 93 setSubDeviceIfNeeded(CachedBluetoothDevice newDevice)94 boolean setSubDeviceIfNeeded(CachedBluetoothDevice newDevice) { 95 final long hiSyncId = newDevice.getHiSyncId(); 96 if (isValidHiSyncId(hiSyncId)) { 97 final CachedBluetoothDevice hearingAidDevice = getCachedDevice(hiSyncId); 98 // Just add one of the hearing aids from a pair in the list that is shown in the UI. 99 // Once there is another device with the same hiSyncId, to add new device as sub 100 // device. 101 if (hearingAidDevice != null) { 102 hearingAidDevice.setSubDevice(newDevice); 103 newDevice.setName(hearingAidDevice.getName()); 104 return true; 105 } 106 } 107 return false; 108 } 109 isValidHiSyncId(long hiSyncId)110 private boolean isValidHiSyncId(long hiSyncId) { 111 return hiSyncId != BluetoothHearingAid.HI_SYNC_ID_INVALID; 112 } 113 isValidGroupId(int groupId)114 private boolean isValidGroupId(int groupId) { 115 return groupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID; 116 } 117 getCachedDevice(long hiSyncId)118 private CachedBluetoothDevice getCachedDevice(long hiSyncId) { 119 for (int i = mCachedDevices.size() - 1; i >= 0; i--) { 120 CachedBluetoothDevice cachedDevice = mCachedDevices.get(i); 121 if (cachedDevice.getHiSyncId() == hiSyncId) { 122 return cachedDevice; 123 } 124 } 125 return null; 126 } 127 128 // To collect all HearingAid devices and call #onHiSyncIdChanged to group device by HiSyncId updateHearingAidsDevices()129 void updateHearingAidsDevices() { 130 final Set<Long> newSyncIdSet = new HashSet<>(); 131 for (CachedBluetoothDevice cachedDevice : mCachedDevices) { 132 // Do nothing if HiSyncId has been assigned 133 if (isValidHiSyncId(cachedDevice.getHiSyncId())) { 134 continue; 135 } 136 HearingAidInfo info = generateHearingAidInfo(cachedDevice); 137 if (info != null) { 138 cachedDevice.setHearingAidInfo(info); 139 if (isValidHiSyncId(info.getHiSyncId())) { 140 newSyncIdSet.add(info.getHiSyncId()); 141 } 142 } 143 } 144 for (Long syncId : newSyncIdSet) { 145 onHiSyncIdChanged(syncId); 146 } 147 } 148 149 // Group devices by hiSyncId 150 @VisibleForTesting onHiSyncIdChanged(long hiSyncId)151 void onHiSyncIdChanged(long hiSyncId) { 152 int firstMatchedIndex = -1; 153 154 for (int i = mCachedDevices.size() - 1; i >= 0; i--) { 155 CachedBluetoothDevice cachedDevice = mCachedDevices.get(i); 156 if (cachedDevice.getHiSyncId() != hiSyncId) { 157 continue; 158 } 159 160 // The remote device supports CSIP, the other ear should be processed as a member 161 // device. Ignore hiSyncId grouping from ASHA here. 162 if (cachedDevice.getProfiles().stream().anyMatch( 163 profile -> profile instanceof CsipSetCoordinatorProfile)) { 164 continue; 165 } 166 167 if (firstMatchedIndex == -1) { 168 // Found the first one 169 firstMatchedIndex = i; 170 continue; 171 } 172 // Found the second one 173 int indexToRemoveFromUi; 174 CachedBluetoothDevice subDevice; 175 CachedBluetoothDevice mainDevice; 176 // Since the hiSyncIds have been updated for a connected pair of hearing aids, 177 // we remove the entry of one the hearing aids from the UI. Unless the 178 // hiSyncId get updated, the system does not know it is a hearing aid, so we add 179 // both the hearing aids as separate entries in the UI first, then remove one 180 // of them after the hiSyncId is populated. We will choose the device that 181 // is not connected to be removed. 182 if (cachedDevice.isConnected()) { 183 mainDevice = cachedDevice; 184 indexToRemoveFromUi = firstMatchedIndex; 185 subDevice = mCachedDevices.get(firstMatchedIndex); 186 } else { 187 mainDevice = mCachedDevices.get(firstMatchedIndex); 188 indexToRemoveFromUi = i; 189 subDevice = cachedDevice; 190 } 191 192 mainDevice.setSubDevice(subDevice); 193 mCachedDevices.remove(indexToRemoveFromUi); 194 log("onHiSyncIdChanged: removed from UI device =" + subDevice 195 + ", with hiSyncId=" + hiSyncId); 196 mBtManager.getEventManager().dispatchDeviceRemoved(subDevice); 197 break; 198 } 199 } 200 201 // @return {@code true}, the event is processed inside the method. It is for updating 202 // hearing aid device on main-sub relationship when receiving connected or disconnected. 203 // @return {@code false}, it is not hearing aid device or to process it same as other profiles onProfileConnectionStateChangedIfProcessed(CachedBluetoothDevice cachedDevice, int state)204 boolean onProfileConnectionStateChangedIfProcessed(CachedBluetoothDevice cachedDevice, 205 int state) { 206 switch (state) { 207 case BluetoothProfile.STATE_CONNECTED: 208 onHiSyncIdChanged(cachedDevice.getHiSyncId()); 209 CachedBluetoothDevice mainDevice = findMainDevice(cachedDevice); 210 if (mainDevice != null) { 211 if (mainDevice.isConnected()) { 212 // Sub/member device is connected and main device is connected 213 // To refresh main device UI 214 mainDevice.refresh(); 215 } else { 216 // Sub/member device is connected and main device is disconnected 217 // To switch content and dispatch to notify UI change 218 switchDeviceContent(mainDevice, cachedDevice); 219 } 220 return true; 221 } 222 break; 223 case BluetoothProfile.STATE_DISCONNECTED: 224 if (cachedDevice.getUnpairing()) { 225 return true; 226 } 227 mainDevice = findMainDevice(cachedDevice); 228 if (mainDevice != null) { 229 // Sub/member device is disconnected and main device exists 230 // To update main device UI 231 mainDevice.refresh(); 232 return true; 233 } 234 CachedBluetoothDevice connectedSecondaryDevice = getConnectedSecondaryDevice( 235 cachedDevice); 236 if (connectedSecondaryDevice != null) { 237 // Main device is disconnected and sub/member device is connected 238 // To switch content and dispatch to notify UI change 239 switchDeviceContent(cachedDevice, connectedSecondaryDevice); 240 return true; 241 } 242 break; 243 } 244 return false; 245 } 246 switchDeviceContent(CachedBluetoothDevice mainDevice, CachedBluetoothDevice secondaryDevice)247 private void switchDeviceContent(CachedBluetoothDevice mainDevice, 248 CachedBluetoothDevice secondaryDevice) { 249 mBtManager.getEventManager().dispatchDeviceRemoved(mainDevice); 250 if (mainDevice.getSubDevice() != null 251 && mainDevice.getSubDevice().equals(secondaryDevice)) { 252 mainDevice.switchSubDeviceContent(); 253 } else { 254 mainDevice.switchMemberDeviceContent(secondaryDevice); 255 } 256 mainDevice.refresh(); 257 // It is necessary to do remove and add for updating the mapping on 258 // preference and device 259 mBtManager.getEventManager().dispatchDeviceAdded(mainDevice); 260 } 261 getConnectedSecondaryDevice(CachedBluetoothDevice cachedDevice)262 private CachedBluetoothDevice getConnectedSecondaryDevice(CachedBluetoothDevice cachedDevice) { 263 if (cachedDevice.getSubDevice() != null && cachedDevice.getSubDevice().isConnected()) { 264 return cachedDevice.getSubDevice(); 265 } 266 return cachedDevice.getMemberDevice().stream().filter( 267 CachedBluetoothDevice::isConnected).findAny().orElse(null); 268 } 269 onActiveDeviceChanged(CachedBluetoothDevice device)270 void onActiveDeviceChanged(CachedBluetoothDevice device) { 271 if (FeatureFlagUtils.isEnabled(mContext, FeatureFlagUtils.SETTINGS_AUDIO_ROUTING)) { 272 if (device.isActiveDevice(BluetoothProfile.HEARING_AID) || device.isActiveDevice( 273 BluetoothProfile.LE_AUDIO)) { 274 setAudioRoutingConfig(device); 275 } else { 276 clearAudioRoutingConfig(); 277 } 278 } 279 } 280 syncDeviceIfNeeded(CachedBluetoothDevice device)281 void syncDeviceIfNeeded(CachedBluetoothDevice device) { 282 final LocalBluetoothProfileManager profileManager = mBtManager.getProfileManager(); 283 final HapClientProfile hap = profileManager.getHapClientProfile(); 284 // Sync preset if device doesn't support synchronization on the remote side 285 if (hap != null && !hap.supportsSynchronizedPresets(device.getDevice())) { 286 final CachedBluetoothDevice mainDevice = findMainDevice(device); 287 if (mainDevice != null) { 288 int mainPresetIndex = hap.getActivePresetIndex(mainDevice.getDevice()); 289 int presetIndex = hap.getActivePresetIndex(device.getDevice()); 290 if (mainPresetIndex != BluetoothHapClient.PRESET_INDEX_UNAVAILABLE 291 && mainPresetIndex != presetIndex) { 292 if (DEBUG) { 293 Log.d(TAG, "syncing preset from " + presetIndex + "->" 294 + mainPresetIndex + ", device=" + device); 295 } 296 hap.selectPreset(device.getDevice(), mainPresetIndex); 297 } 298 } 299 } 300 } 301 setAudioRoutingConfig(CachedBluetoothDevice device)302 private void setAudioRoutingConfig(CachedBluetoothDevice device) { 303 AudioDeviceAttributes hearingDeviceAttributes = 304 mRoutingHelper.getMatchedHearingDeviceAttributes(device); 305 if (hearingDeviceAttributes == null) { 306 Log.w(TAG, "Can not find expected AudioDeviceAttributes for hearing device: " 307 + device.getDevice().getAnonymizedAddress()); 308 return; 309 } 310 311 final int callRoutingValue = Settings.Secure.getInt(mContentResolver, 312 Settings.Secure.HEARING_AID_CALL_ROUTING, 313 HearingAidAudioRoutingConstants.RoutingValue.AUTO); 314 final int mediaRoutingValue = Settings.Secure.getInt(mContentResolver, 315 Settings.Secure.HEARING_AID_MEDIA_ROUTING, 316 HearingAidAudioRoutingConstants.RoutingValue.AUTO); 317 final int ringtoneRoutingValue = Settings.Secure.getInt(mContentResolver, 318 Settings.Secure.HEARING_AID_RINGTONE_ROUTING, 319 HearingAidAudioRoutingConstants.RoutingValue.AUTO); 320 final int systemSoundsRoutingValue = Settings.Secure.getInt(mContentResolver, 321 Settings.Secure.HEARING_AID_NOTIFICATION_ROUTING, 322 HearingAidAudioRoutingConstants.RoutingValue.AUTO); 323 324 setPreferredDeviceRoutingStrategies( 325 HearingAidAudioRoutingConstants.CALL_ROUTING_ATTRIBUTES, 326 hearingDeviceAttributes, callRoutingValue); 327 setPreferredDeviceRoutingStrategies( 328 HearingAidAudioRoutingConstants.MEDIA_ROUTING_ATTRIBUTES, 329 hearingDeviceAttributes, mediaRoutingValue); 330 setPreferredDeviceRoutingStrategies( 331 HearingAidAudioRoutingConstants.RINGTONE_ROUTING_ATTRIBUTES, 332 hearingDeviceAttributes, ringtoneRoutingValue); 333 setPreferredDeviceRoutingStrategies( 334 HearingAidAudioRoutingConstants.NOTIFICATION_ROUTING_ATTRIBUTES, 335 hearingDeviceAttributes, systemSoundsRoutingValue); 336 } 337 clearAudioRoutingConfig()338 private void clearAudioRoutingConfig() { 339 // Don't need to pass hearingDevice when we want to reset it (set to AUTO). 340 setPreferredDeviceRoutingStrategies( 341 HearingAidAudioRoutingConstants.CALL_ROUTING_ATTRIBUTES, 342 /* hearingDevice = */ null, HearingAidAudioRoutingConstants.RoutingValue.AUTO); 343 setPreferredDeviceRoutingStrategies( 344 HearingAidAudioRoutingConstants.MEDIA_ROUTING_ATTRIBUTES, 345 /* hearingDevice = */ null, HearingAidAudioRoutingConstants.RoutingValue.AUTO); 346 setPreferredDeviceRoutingStrategies( 347 HearingAidAudioRoutingConstants.RINGTONE_ROUTING_ATTRIBUTES, 348 /* hearingDevice = */ null, HearingAidAudioRoutingConstants.RoutingValue.AUTO); 349 setPreferredDeviceRoutingStrategies( 350 HearingAidAudioRoutingConstants.NOTIFICATION_ROUTING_ATTRIBUTES, 351 /* hearingDevice = */ null, HearingAidAudioRoutingConstants.RoutingValue.AUTO); 352 } 353 setPreferredDeviceRoutingStrategies(int[] attributeSdkUsageList, AudioDeviceAttributes hearingDevice, @HearingAidAudioRoutingConstants.RoutingValue int routingValue)354 private void setPreferredDeviceRoutingStrategies(int[] attributeSdkUsageList, 355 AudioDeviceAttributes hearingDevice, 356 @HearingAidAudioRoutingConstants.RoutingValue int routingValue) { 357 final List<AudioProductStrategy> supportedStrategies = 358 mRoutingHelper.getSupportedStrategies(attributeSdkUsageList); 359 360 final boolean status = mRoutingHelper.setPreferredDeviceRoutingStrategies( 361 supportedStrategies, hearingDevice, routingValue); 362 363 if (!status) { 364 Log.w(TAG, "routingStrategies: " + supportedStrategies.toString() + "routingValue: " 365 + routingValue + " fail to configure AudioProductStrategy"); 366 } 367 } 368 findMainDevice(CachedBluetoothDevice device)369 CachedBluetoothDevice findMainDevice(CachedBluetoothDevice device) { 370 if (device == null || mCachedDevices == null) { 371 return null; 372 } 373 374 for (CachedBluetoothDevice cachedDevice : mCachedDevices) { 375 if (isValidGroupId(cachedDevice.getGroupId())) { 376 Set<CachedBluetoothDevice> memberSet = cachedDevice.getMemberDevice(); 377 for (CachedBluetoothDevice memberDevice : memberSet) { 378 if (memberDevice != null && memberDevice.equals(device)) { 379 return cachedDevice; 380 } 381 } 382 } 383 if (isValidHiSyncId(cachedDevice.getHiSyncId())) { 384 CachedBluetoothDevice subDevice = cachedDevice.getSubDevice(); 385 if (subDevice != null && subDevice.equals(device)) { 386 return cachedDevice; 387 } 388 } 389 } 390 return null; 391 } 392 generateHearingAidInfo(CachedBluetoothDevice cachedDevice)393 private HearingAidInfo generateHearingAidInfo(CachedBluetoothDevice cachedDevice) { 394 final LocalBluetoothProfileManager profileManager = mBtManager.getProfileManager(); 395 396 final HearingAidProfile asha = profileManager.getHearingAidProfile(); 397 if (asha == null) { 398 Log.w(TAG, "HearingAidProfile is not supported on this device"); 399 } else { 400 long hiSyncId = asha.getHiSyncId(cachedDevice.getDevice()); 401 if (isValidHiSyncId(hiSyncId)) { 402 final HearingAidInfo info = new HearingAidInfo.Builder() 403 .setAshaDeviceSide(asha.getDeviceSide(cachedDevice.getDevice())) 404 .setAshaDeviceMode(asha.getDeviceMode(cachedDevice.getDevice())) 405 .setHiSyncId(hiSyncId) 406 .build(); 407 if (DEBUG) { 408 Log.d(TAG, "generateHearingAidInfo, " + cachedDevice + ", info=" + info); 409 } 410 return info; 411 } 412 } 413 414 final HapClientProfile hapClientProfile = profileManager.getHapClientProfile(); 415 final LeAudioProfile leAudioProfile = profileManager.getLeAudioProfile(); 416 if (hapClientProfile == null || leAudioProfile == null) { 417 Log.w(TAG, "HapClientProfile or LeAudioProfile is not supported on this device"); 418 } else if (cachedDevice.getProfiles().stream().anyMatch( 419 p -> p instanceof HapClientProfile)) { 420 int audioLocation = leAudioProfile.getAudioLocation(cachedDevice.getDevice()); 421 int hearingAidType = hapClientProfile.getHearingAidType(cachedDevice.getDevice()); 422 if (audioLocation != BluetoothLeAudio.AUDIO_LOCATION_INVALID 423 && hearingAidType != HapClientProfile.HearingAidType.TYPE_INVALID) { 424 final HearingAidInfo info = new HearingAidInfo.Builder() 425 .setLeAudioLocation(audioLocation) 426 .setHapDeviceType(hearingAidType) 427 .build(); 428 if (DEBUG) { 429 Log.d(TAG, "generateHearingAidInfo, " + cachedDevice + ", info=" + info); 430 } 431 return info; 432 } 433 } 434 435 return null; 436 } 437 log(String msg)438 private void log(String msg) { 439 if (DEBUG) { 440 Log.d(TAG, msg); 441 } 442 } 443 }