1 /* 2 * Copyright (C) 2024 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 17 package com.android.settingslib.bluetooth; 18 19 import static android.bluetooth.AudioInputControl.MUTE_NOT_MUTED; 20 import static android.bluetooth.AudioInputControl.MUTE_MUTED; 21 import static android.bluetooth.BluetoothDevice.BOND_BONDED; 22 23 import static com.android.settingslib.bluetooth.AmbientVolumeUi.SIDE_UNIFIED; 24 import static com.android.settingslib.bluetooth.AmbientVolumeUi.VALID_SIDES; 25 import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_INVALID; 26 import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT; 27 import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT; 28 import static com.android.settingslib.bluetooth.HearingDeviceLocalDataManager.Data.INVALID_VOLUME; 29 30 import android.bluetooth.BluetoothDevice; 31 import android.bluetooth.BluetoothProfile; 32 import android.content.Context; 33 import android.util.ArraySet; 34 import android.util.Log; 35 import android.widget.Toast; 36 37 import androidx.annotation.NonNull; 38 import androidx.annotation.Nullable; 39 import androidx.annotation.VisibleForTesting; 40 41 import com.android.settingslib.R; 42 import com.android.settingslib.utils.ThreadUtils; 43 44 import com.google.common.collect.BiMap; 45 import com.google.common.collect.HashBiMap; 46 47 import java.util.Map; 48 import java.util.Set; 49 50 /** This class controls ambient volume UI with local and remote ambient data. */ 51 public class AmbientVolumeUiController implements 52 HearingDeviceLocalDataManager.OnDeviceLocalDataChangeListener, 53 AmbientVolumeController.AmbientVolumeControlCallback, 54 AmbientVolumeUi.AmbientVolumeUiListener, BluetoothCallback, CachedBluetoothDevice.Callback { 55 56 private static final boolean DEBUG = true; 57 private static final String TAG = "AmbientVolumeUiController"; 58 59 private final Context mContext; 60 private final LocalBluetoothProfileManager mProfileManager; 61 private final BluetoothEventManager mEventManager; 62 private final AmbientVolumeUi mAmbientLayout; 63 private final AmbientVolumeController mVolumeController; 64 private final HearingDeviceLocalDataManager mLocalDataManager; 65 66 private final Set<CachedBluetoothDevice> mCachedDevices = new ArraySet<>(); 67 private final BiMap<Integer, BluetoothDevice> mSideToDeviceMap = HashBiMap.create(); 68 private CachedBluetoothDevice mCachedDevice; 69 private boolean mShowUiWhenLocalDataExist = true; 70 AmbientVolumeUiController(@onNull Context context, @NonNull LocalBluetoothManager bluetoothManager, @NonNull AmbientVolumeUi ambientLayout)71 public AmbientVolumeUiController(@NonNull Context context, 72 @NonNull LocalBluetoothManager bluetoothManager, 73 @NonNull AmbientVolumeUi ambientLayout) { 74 mContext = context; 75 mProfileManager = bluetoothManager.getProfileManager(); 76 mEventManager = bluetoothManager.getEventManager(); 77 mAmbientLayout = ambientLayout; 78 mAmbientLayout.setListener(this); 79 mVolumeController = new AmbientVolumeController(mProfileManager, this); 80 mLocalDataManager = new HearingDeviceLocalDataManager(context); 81 mLocalDataManager.setOnDeviceLocalDataChangeListener(this, 82 ThreadUtils.getBackgroundExecutor()); 83 mLocalDataManager.start(); 84 } 85 86 @VisibleForTesting AmbientVolumeUiController(@onNull Context context, @NonNull LocalBluetoothManager bluetoothManager, @NonNull AmbientVolumeUi ambientLayout, @NonNull AmbientVolumeController volumeController, @NonNull HearingDeviceLocalDataManager localDataManager)87 public AmbientVolumeUiController(@NonNull Context context, 88 @NonNull LocalBluetoothManager bluetoothManager, 89 @NonNull AmbientVolumeUi ambientLayout, 90 @NonNull AmbientVolumeController volumeController, 91 @NonNull HearingDeviceLocalDataManager localDataManager) { 92 mContext = context; 93 mProfileManager = bluetoothManager.getProfileManager(); 94 mEventManager = bluetoothManager.getEventManager(); 95 mAmbientLayout = ambientLayout; 96 mVolumeController = volumeController; 97 mLocalDataManager = localDataManager; 98 } 99 100 101 @Override onDeviceLocalDataChange(@onNull String address, @Nullable HearingDeviceLocalDataManager.Data data)102 public void onDeviceLocalDataChange(@NonNull String address, 103 @Nullable HearingDeviceLocalDataManager.Data data) { 104 if (data == null) { 105 // The local data is removed because the device is unpaired, do nothing 106 return; 107 } 108 if (DEBUG) { 109 Log.d(TAG, "onDeviceLocalDataChange, address:" + address + ", data:" + data); 110 } 111 for (BluetoothDevice device : mSideToDeviceMap.values()) { 112 if (device.getAnonymizedAddress().equals(address)) { 113 postOnMainThread(() -> loadLocalDataToUi(device)); 114 return; 115 } 116 } 117 } 118 119 @Override onVolumeControlServiceConnected()120 public void onVolumeControlServiceConnected() { 121 mCachedDevices.forEach(device -> mVolumeController.registerCallback( 122 ThreadUtils.getBackgroundExecutor(), device.getDevice())); 123 } 124 125 @Override onAmbientChanged(@onNull BluetoothDevice device, int gainSettings)126 public void onAmbientChanged(@NonNull BluetoothDevice device, int gainSettings) { 127 if (DEBUG) { 128 Log.d(TAG, "onAmbientChanged, value:" + gainSettings + ", device:" + device); 129 } 130 HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(device); 131 final boolean expanded = mAmbientLayout.isExpanded(); 132 final boolean isInitiatedFromUi = (expanded && data.ambient() == gainSettings) 133 || (!expanded && data.groupAmbient() == gainSettings); 134 if (isInitiatedFromUi) { 135 // The change is initiated from UI, no need to update UI 136 return; 137 } 138 139 // We have to check if we need to expand the controls by getting all remote 140 // device's ambient value, delay for a while to wait all remote devices update 141 // to the latest value to avoid unnecessary expand action. 142 postDelayedOnMainThread(this::refresh, 1200L); 143 } 144 145 @Override onMuteChanged(@onNull BluetoothDevice device, int mute)146 public void onMuteChanged(@NonNull BluetoothDevice device, int mute) { 147 if (DEBUG) { 148 Log.d(TAG, "onMuteChanged, mute:" + mute + ", device:" + device); 149 } 150 final boolean muted = mAmbientLayout.isMuted(); 151 boolean isInitiatedFromUi = (muted && mute == MUTE_MUTED) 152 || (!muted && mute == MUTE_NOT_MUTED); 153 if (isInitiatedFromUi) { 154 // The change is initiated from UI, no need to update UI 155 return; 156 } 157 158 // We have to check if we need to mute the devices by getting all remote 159 // device's mute state, delay for a while to wait all remote devices update 160 // to the latest value. 161 postDelayedOnMainThread(this::refresh, 1200L); 162 } 163 164 @Override onCommandFailed(@onNull BluetoothDevice device)165 public void onCommandFailed(@NonNull BluetoothDevice device) { 166 Log.w(TAG, "onCommandFailed, device:" + device); 167 postOnMainThread(() -> { 168 showErrorToast(R.string.bluetooth_hearing_device_ambient_error); 169 refresh(); 170 }); 171 } 172 173 @Override onExpandIconClick()174 public void onExpandIconClick() { 175 mSideToDeviceMap.forEach((s, d) -> { 176 if (!mAmbientLayout.isMuted()) { 177 // Apply previous collapsed/expanded volume to remote device 178 HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(d); 179 int volume = mAmbientLayout.isExpanded() 180 ? data.ambient() : data.groupAmbient(); 181 mVolumeController.setAmbient(d, volume); 182 } 183 // Update new value to local data 184 mLocalDataManager.updateAmbientControlExpanded(d, 185 mAmbientLayout.isExpanded()); 186 }); 187 mLocalDataManager.flush(); 188 } 189 190 @Override onAmbientVolumeIconClick()191 public void onAmbientVolumeIconClick() { 192 if (!mAmbientLayout.isMuted()) { 193 loadLocalDataToUi(); 194 } 195 for (BluetoothDevice device : mSideToDeviceMap.values()) { 196 mVolumeController.setMuted(device, mAmbientLayout.isMuted()); 197 } 198 } 199 200 @Override onSliderValueChange(int side, int value)201 public void onSliderValueChange(int side, int value) { 202 if (DEBUG) { 203 Log.d(TAG, "onSliderValueChange: side=" + side + ", value=" + value); 204 } 205 setVolumeIfValid(side, value); 206 207 Runnable setAmbientRunnable = () -> { 208 if (side == SIDE_UNIFIED) { 209 mSideToDeviceMap.forEach((s, d) -> mVolumeController.setAmbient(d, value)); 210 } else { 211 final BluetoothDevice device = mSideToDeviceMap.get(side); 212 mVolumeController.setAmbient(device, value); 213 } 214 }; 215 216 if (mAmbientLayout.isMuted()) { 217 // User drag on the volume slider when muted. Unmute the devices first. 218 mAmbientLayout.setMuted(false); 219 220 for (BluetoothDevice device : mSideToDeviceMap.values()) { 221 mVolumeController.setMuted(device, false); 222 } 223 // Restore the value before muted 224 loadLocalDataToUi(); 225 // Delay set ambient on remote device since the immediately sequential command 226 // might get failed sometimes 227 postDelayedOnMainThread(setAmbientRunnable, 1000L); 228 } else { 229 setAmbientRunnable.run(); 230 } 231 } 232 233 @Override onProfileConnectionStateChanged(@onNull CachedBluetoothDevice cachedDevice, int state, int bluetoothProfile)234 public void onProfileConnectionStateChanged(@NonNull CachedBluetoothDevice cachedDevice, 235 int state, int bluetoothProfile) { 236 if (bluetoothProfile == BluetoothProfile.VOLUME_CONTROL 237 && state == BluetoothProfile.STATE_CONNECTED 238 && mCachedDevices.contains(cachedDevice)) { 239 // After VCP connected, AICS may not ready yet and still return invalid value, delay 240 // a while to wait AICS ready as a workaround 241 postDelayedOnMainThread(this::refresh, 1000L); 242 } 243 } 244 245 @Override onDeviceAttributesChanged()246 public void onDeviceAttributesChanged() { 247 mCachedDevices.forEach(device -> { 248 device.unregisterCallback(this); 249 mVolumeController.unregisterCallback(device.getDevice()); 250 }); 251 postOnMainThread(()-> { 252 loadDevice(mCachedDevice); 253 ThreadUtils.postOnBackgroundThread(()-> { 254 mCachedDevices.forEach(device -> { 255 device.registerCallback(ThreadUtils.getBackgroundExecutor(), this); 256 mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(), 257 device.getDevice()); 258 }); 259 }); 260 }); 261 } 262 263 /** 264 * Registers callbacks and listeners, this should be called when needs to start listening to 265 * events. 266 */ start()267 public void start() { 268 mEventManager.registerCallback(this); 269 mLocalDataManager.start(); 270 mCachedDevices.forEach(device -> { 271 device.registerCallback(ThreadUtils.getBackgroundExecutor(), this); 272 mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(), 273 device.getDevice()); 274 }); 275 } 276 277 /** 278 * Unregisters callbacks and listeners, this should be called when no longer needs to listen to 279 * events. 280 */ stop()281 public void stop() { 282 mEventManager.unregisterCallback(this); 283 mLocalDataManager.stop(); 284 mCachedDevices.forEach(device -> { 285 device.unregisterCallback(this); 286 mVolumeController.unregisterCallback(device.getDevice()); 287 }); 288 } 289 290 /** 291 * Loads all devices in the same set with {@code cachedDevice} and create corresponding sliders. 292 * 293 * <p>If the devices has valid ambient control points, the ambient volume UI will be visible. 294 * @param cachedDevice the remote device 295 */ loadDevice(CachedBluetoothDevice cachedDevice)296 public void loadDevice(CachedBluetoothDevice cachedDevice) { 297 if (DEBUG) { 298 Log.d(TAG, "loadDevice, device=" + cachedDevice); 299 } 300 mCachedDevice = cachedDevice; 301 mSideToDeviceMap.clear(); 302 mCachedDevices.clear(); 303 boolean deviceSupportVcp = 304 cachedDevice != null && cachedDevice.getProfiles().stream().anyMatch( 305 p -> p instanceof VolumeControlProfile); 306 if (!deviceSupportVcp) { 307 mAmbientLayout.setVisible(false); 308 return; 309 } 310 311 // load devices in the same set 312 if (VALID_SIDES.contains(cachedDevice.getDeviceSide()) 313 && cachedDevice.getBondState() == BOND_BONDED) { 314 mSideToDeviceMap.put(cachedDevice.getDeviceSide(), cachedDevice.getDevice()); 315 mCachedDevices.add(cachedDevice); 316 } 317 for (CachedBluetoothDevice memberDevice : cachedDevice.getMemberDevice()) { 318 if (VALID_SIDES.contains(memberDevice.getDeviceSide()) 319 && memberDevice.getBondState() == BOND_BONDED) { 320 mSideToDeviceMap.put(memberDevice.getDeviceSide(), memberDevice.getDevice()); 321 mCachedDevices.add(memberDevice); 322 } 323 } 324 325 mAmbientLayout.setExpandable(mSideToDeviceMap.size() > 1); 326 mAmbientLayout.setupSliders(mSideToDeviceMap); 327 refresh(); 328 } 329 330 /** Refreshes the ambient volume UI. */ refresh()331 public void refresh() { 332 if (isAmbientControlAvailable()) { 333 mAmbientLayout.setVisible(true); 334 loadRemoteDataToUi(); 335 } else { 336 mAmbientLayout.setVisible(false); 337 } 338 } 339 340 /** Sets if the ambient volume UI should be visible when local ambient data exist. */ setShowUiWhenLocalDataExist(boolean shouldShow)341 public void setShowUiWhenLocalDataExist(boolean shouldShow) { 342 mShowUiWhenLocalDataExist = shouldShow; 343 } 344 345 /** Updates the ambient sliders according to current state. */ updateSliderUi()346 private void updateSliderUi() { 347 boolean isAnySliderEnabled = false; 348 for (Map.Entry<Integer, BluetoothDevice> entry : mSideToDeviceMap.entrySet()) { 349 final int side = entry.getKey(); 350 final BluetoothDevice device = entry.getValue(); 351 final boolean enabled = isDeviceConnectedToVcp(device) 352 && mVolumeController.isAmbientControlAvailable(device); 353 isAnySliderEnabled |= enabled; 354 mAmbientLayout.setSliderEnabled(side, enabled); 355 } 356 mAmbientLayout.setSliderEnabled(SIDE_UNIFIED, isAnySliderEnabled); 357 mAmbientLayout.updateLayout(); 358 } 359 360 /** Sets the ambient to the corresponding control slider. */ setVolumeIfValid(int side, int volume)361 private void setVolumeIfValid(int side, int volume) { 362 if (volume == INVALID_VOLUME) { 363 return; 364 } 365 mAmbientLayout.setSliderValue(side, volume); 366 // Update new value to local data 367 if (side == SIDE_UNIFIED) { 368 mSideToDeviceMap.forEach((s, d) -> mLocalDataManager.updateGroupAmbient(d, volume)); 369 } else { 370 mLocalDataManager.updateAmbient(mSideToDeviceMap.get(side), volume); 371 } 372 mLocalDataManager.flush(); 373 } 374 loadLocalDataToUi()375 private void loadLocalDataToUi() { 376 mSideToDeviceMap.forEach((s, d) -> loadLocalDataToUi(d)); 377 } 378 loadLocalDataToUi(BluetoothDevice device)379 private void loadLocalDataToUi(BluetoothDevice device) { 380 final HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(device); 381 if (DEBUG) { 382 Log.d(TAG, "loadLocalDataToUi, data=" + data + ", device=" + device); 383 } 384 if (isDeviceConnectedToVcp(device) && !mAmbientLayout.isMuted()) { 385 final int side = mSideToDeviceMap.inverse().getOrDefault(device, SIDE_INVALID); 386 setVolumeIfValid(side, data.ambient()); 387 setVolumeIfValid(SIDE_UNIFIED, data.groupAmbient()); 388 } 389 setAmbientControlExpanded(data.ambientControlExpanded()); 390 updateSliderUi(); 391 } 392 loadRemoteDataToUi()393 private void loadRemoteDataToUi() { 394 BluetoothDevice leftDevice = mSideToDeviceMap.get(SIDE_LEFT); 395 AmbientVolumeController.RemoteAmbientState leftState = 396 mVolumeController.refreshAmbientState(leftDevice); 397 BluetoothDevice rightDevice = mSideToDeviceMap.get(SIDE_RIGHT); 398 AmbientVolumeController.RemoteAmbientState rightState = 399 mVolumeController.refreshAmbientState(rightDevice); 400 if (DEBUG) { 401 Log.d(TAG, "loadRemoteDataToUi, left=" + leftState + ", right=" + rightState); 402 } 403 mSideToDeviceMap.forEach((side, device) -> { 404 int ambientMax = mVolumeController.getAmbientMax(device); 405 int ambientMin = mVolumeController.getAmbientMin(device); 406 if (ambientMin != ambientMax) { 407 mAmbientLayout.setSliderRange(side, ambientMin, ambientMax); 408 mAmbientLayout.setSliderRange(SIDE_UNIFIED, ambientMin, ambientMax); 409 } 410 }); 411 412 // Update ambient volume 413 final int leftAmbient = leftState != null ? leftState.gainSetting() : INVALID_VOLUME; 414 final int rightAmbient = rightState != null ? rightState.gainSetting() : INVALID_VOLUME; 415 if (mAmbientLayout.isExpanded()) { 416 setVolumeIfValid(SIDE_LEFT, leftAmbient); 417 setVolumeIfValid(SIDE_RIGHT, rightAmbient); 418 } else { 419 if (leftAmbient != rightAmbient && leftAmbient != INVALID_VOLUME 420 && rightAmbient != INVALID_VOLUME) { 421 setVolumeIfValid(SIDE_LEFT, leftAmbient); 422 setVolumeIfValid(SIDE_RIGHT, rightAmbient); 423 setAmbientControlExpanded(true); 424 } else { 425 int unifiedAmbient = leftAmbient != INVALID_VOLUME ? leftAmbient : rightAmbient; 426 setVolumeIfValid(SIDE_UNIFIED, unifiedAmbient); 427 } 428 } 429 // Initialize local data between side and group value 430 initLocalAmbientDataIfNeeded(); 431 432 // Update mute state 433 boolean mutable = true; 434 boolean muted = true; 435 if (isDeviceConnectedToVcp(leftDevice) && leftState != null) { 436 mutable &= leftState.isMutable(); 437 muted &= leftState.isMuted(); 438 } 439 if (isDeviceConnectedToVcp(rightDevice) && rightState != null) { 440 mutable &= rightState.isMutable(); 441 muted &= rightState.isMuted(); 442 } 443 mAmbientLayout.setMutable(mutable); 444 mAmbientLayout.setMuted(muted); 445 446 // Ensure remote device mute state is synced 447 syncMuteStateIfNeeded(leftDevice, leftState, muted); 448 syncMuteStateIfNeeded(rightDevice, rightState, muted); 449 450 updateSliderUi(); 451 } 452 setAmbientControlExpanded(boolean expanded)453 private void setAmbientControlExpanded(boolean expanded) { 454 mAmbientLayout.setExpanded(expanded); 455 mSideToDeviceMap.forEach((s, d) -> { 456 // Update new value to local data 457 mLocalDataManager.updateAmbientControlExpanded(d, expanded); 458 }); 459 mLocalDataManager.flush(); 460 } 461 462 /** Checks if any device in the same set has valid ambient control points */ isAmbientControlAvailable()463 public boolean isAmbientControlAvailable() { 464 for (BluetoothDevice device : mSideToDeviceMap.values()) { 465 if (mShowUiWhenLocalDataExist) { 466 // Found local ambient data 467 if (mLocalDataManager.get(device).hasAmbientData()) { 468 return true; 469 } 470 } 471 // Found remote ambient control points 472 if (mVolumeController.isAmbientControlAvailable(device)) { 473 return true; 474 } 475 } 476 return false; 477 } 478 initLocalAmbientDataIfNeeded()479 private void initLocalAmbientDataIfNeeded() { 480 int smallerVolumeAmongGroup = Integer.MAX_VALUE; 481 for (BluetoothDevice device : mSideToDeviceMap.values()) { 482 HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(device); 483 if (data.ambient() != INVALID_VOLUME) { 484 smallerVolumeAmongGroup = Math.min(data.ambient(), smallerVolumeAmongGroup); 485 } else if (data.groupAmbient() != INVALID_VOLUME) { 486 // Initialize side ambient from group ambient value 487 mLocalDataManager.updateAmbient(device, data.groupAmbient()); 488 } 489 } 490 if (smallerVolumeAmongGroup != Integer.MAX_VALUE) { 491 for (BluetoothDevice device : mSideToDeviceMap.values()) { 492 HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(device); 493 if (data.groupAmbient() == INVALID_VOLUME) { 494 // Initialize group ambient from smaller side ambient value 495 mLocalDataManager.updateGroupAmbient(device, smallerVolumeAmongGroup); 496 } 497 } 498 } 499 mLocalDataManager.flush(); 500 } 501 syncMuteStateIfNeeded(@ullable BluetoothDevice device, @Nullable AmbientVolumeController.RemoteAmbientState state, boolean muted)502 private void syncMuteStateIfNeeded(@Nullable BluetoothDevice device, 503 @Nullable AmbientVolumeController.RemoteAmbientState state, boolean muted) { 504 if (isDeviceConnectedToVcp(device) && state != null && state.isMutable()) { 505 if (state.isMuted() != muted) { 506 mVolumeController.setMuted(device, muted); 507 } 508 } 509 } 510 isDeviceConnectedToVcp(@ullable BluetoothDevice device)511 private boolean isDeviceConnectedToVcp(@Nullable BluetoothDevice device) { 512 return device != null && device.isConnected() 513 && mProfileManager.getVolumeControlProfile().getConnectionStatus(device) 514 == BluetoothProfile.STATE_CONNECTED; 515 } 516 postOnMainThread(Runnable runnable)517 private void postOnMainThread(Runnable runnable) { 518 mContext.getMainThreadHandler().post(runnable); 519 } 520 postDelayedOnMainThread(Runnable runnable, long delay)521 private void postDelayedOnMainThread(Runnable runnable, long delay) { 522 mContext.getMainThreadHandler().postDelayed(runnable, delay); 523 } 524 showErrorToast(int stringResId)525 private void showErrorToast(int stringResId) { 526 Toast.makeText(mContext, stringResId, Toast.LENGTH_SHORT).show(); 527 } 528 } 529