1 /* 2 * Copyright 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.media; 17 18 import static android.media.MediaRoute2Info.TYPE_BLE_HEADSET; 19 import static android.media.MediaRoute2Info.TYPE_BLUETOOTH_A2DP; 20 import static android.media.MediaRoute2Info.TYPE_BUILTIN_SPEAKER; 21 import static android.media.MediaRoute2Info.TYPE_DOCK; 22 import static android.media.MediaRoute2Info.TYPE_GROUP; 23 import static android.media.MediaRoute2Info.TYPE_HDMI; 24 import static android.media.MediaRoute2Info.TYPE_HDMI_ARC; 25 import static android.media.MediaRoute2Info.TYPE_HDMI_EARC; 26 import static android.media.MediaRoute2Info.TYPE_HEARING_AID; 27 import static android.media.MediaRoute2Info.TYPE_REMOTE_AUDIO_VIDEO_RECEIVER; 28 import static android.media.MediaRoute2Info.TYPE_REMOTE_SPEAKER; 29 import static android.media.MediaRoute2Info.TYPE_REMOTE_TV; 30 import static android.media.MediaRoute2Info.TYPE_UNKNOWN; 31 import static android.media.MediaRoute2Info.TYPE_USB_ACCESSORY; 32 import static android.media.MediaRoute2Info.TYPE_USB_DEVICE; 33 import static android.media.MediaRoute2Info.TYPE_USB_HEADSET; 34 import static android.media.MediaRoute2Info.TYPE_WIRED_HEADPHONES; 35 import static android.media.MediaRoute2Info.TYPE_WIRED_HEADSET; 36 import static android.media.RouteListingPreference.Item.FLAG_ONGOING_SESSION; 37 import static android.media.RouteListingPreference.Item.FLAG_ONGOING_SESSION_MANAGED; 38 import static android.media.RouteListingPreference.Item.FLAG_SUGGESTED; 39 import static android.media.RouteListingPreference.Item.SUBTEXT_AD_ROUTING_DISALLOWED; 40 import static android.media.RouteListingPreference.Item.SUBTEXT_CUSTOM; 41 import static android.media.RouteListingPreference.Item.SUBTEXT_DEVICE_LOW_POWER; 42 import static android.media.RouteListingPreference.Item.SUBTEXT_DOWNLOADED_CONTENT_ROUTING_DISALLOWED; 43 import static android.media.RouteListingPreference.Item.SUBTEXT_ERROR_UNKNOWN; 44 import static android.media.RouteListingPreference.Item.SUBTEXT_NONE; 45 import static android.media.RouteListingPreference.Item.SUBTEXT_SUBSCRIPTION_REQUIRED; 46 import static android.media.RouteListingPreference.Item.SUBTEXT_TRACK_UNSUPPORTED; 47 import static android.media.RouteListingPreference.Item.SUBTEXT_UNAUTHORIZED; 48 49 import static com.android.settingslib.media.LocalMediaManager.MediaDeviceState.STATE_SELECTED; 50 import static com.android.settingslib.media.MediaDevice.SelectionBehavior.SELECTION_BEHAVIOR_TRANSFER; 51 52 import android.annotation.NonNull; 53 import android.annotation.Nullable; 54 import android.annotation.SuppressLint; 55 import android.content.Context; 56 import android.graphics.drawable.Drawable; 57 import android.media.MediaRoute2Info; 58 import android.media.NearbyDevice; 59 import android.media.RouteListingPreference; 60 import android.os.Build; 61 import android.text.TextUtils; 62 import android.util.Log; 63 64 import androidx.annotation.DoNotInline; 65 import androidx.annotation.IntDef; 66 import androidx.annotation.RequiresApi; 67 import androidx.annotation.VisibleForTesting; 68 69 import com.android.settingslib.R; 70 71 import java.lang.annotation.Retention; 72 import java.lang.annotation.RetentionPolicy; 73 import java.util.ArrayList; 74 import java.util.List; 75 76 /** 77 * MediaDevice represents a media device(such like Bluetooth device, cast device and phone device). 78 */ 79 public abstract class MediaDevice implements Comparable<MediaDevice> { 80 private static final String TAG = "MediaDevice"; 81 82 @Retention(RetentionPolicy.SOURCE) 83 @IntDef({MediaDeviceType.TYPE_UNKNOWN, 84 MediaDeviceType.TYPE_PHONE_DEVICE, 85 MediaDeviceType.TYPE_USB_C_AUDIO_DEVICE, 86 MediaDeviceType.TYPE_3POINT5_MM_AUDIO_DEVICE, 87 MediaDeviceType.TYPE_FAST_PAIR_BLUETOOTH_DEVICE, 88 MediaDeviceType.TYPE_BLUETOOTH_DEVICE, 89 MediaDeviceType.TYPE_CAST_DEVICE, 90 MediaDeviceType.TYPE_CAST_GROUP_DEVICE, 91 MediaDeviceType.TYPE_REMOTE_AUDIO_VIDEO_RECEIVER}) 92 public @interface MediaDeviceType { 93 int TYPE_UNKNOWN = 0; 94 int TYPE_PHONE_DEVICE = 1; 95 int TYPE_USB_C_AUDIO_DEVICE = 2; 96 int TYPE_3POINT5_MM_AUDIO_DEVICE = 3; 97 int TYPE_FAST_PAIR_BLUETOOTH_DEVICE = 4; 98 int TYPE_BLUETOOTH_DEVICE = 5; 99 int TYPE_CAST_DEVICE = 6; 100 int TYPE_CAST_GROUP_DEVICE = 7; 101 int TYPE_REMOTE_AUDIO_VIDEO_RECEIVER = 8; 102 } 103 104 @Retention(RetentionPolicy.SOURCE) 105 @IntDef({SelectionBehavior.SELECTION_BEHAVIOR_NONE, 106 SelectionBehavior.SELECTION_BEHAVIOR_TRANSFER, 107 SelectionBehavior.SELECTION_BEHAVIOR_GO_TO_APP 108 }) 109 public @interface SelectionBehavior { 110 int SELECTION_BEHAVIOR_NONE = 0; 111 int SELECTION_BEHAVIOR_TRANSFER = 1; 112 int SELECTION_BEHAVIOR_GO_TO_APP = 2; 113 } 114 115 @VisibleForTesting 116 int mType; 117 118 private int mConnectedRecord; 119 private int mState; 120 @NearbyDevice.RangeZone 121 private int mRangeZone = NearbyDevice.RANGE_UNKNOWN; 122 123 protected final Context mContext; 124 protected final MediaRoute2Info mRouteInfo; 125 protected final RouteListingPreference.Item mItem; 126 MediaDevice( @onNull Context context, @Nullable MediaRoute2Info info, @Nullable RouteListingPreference.Item item)127 MediaDevice( 128 @NonNull Context context, 129 @Nullable MediaRoute2Info info, 130 @Nullable RouteListingPreference.Item item) { 131 mContext = context; 132 mRouteInfo = info; 133 mItem = item; 134 setType(info); 135 } 136 137 // MediaRoute2Info.getType was made public on API 34, but exists since API 30. 138 @SuppressWarnings("NewApi") setType(MediaRoute2Info info)139 private void setType(MediaRoute2Info info) { 140 if (info == null) { 141 mType = MediaDeviceType.TYPE_BLUETOOTH_DEVICE; 142 return; 143 } 144 switch (info.getType()) { 145 case TYPE_GROUP: 146 mType = MediaDeviceType.TYPE_CAST_GROUP_DEVICE; 147 break; 148 case TYPE_BUILTIN_SPEAKER: 149 mType = MediaDeviceType.TYPE_PHONE_DEVICE; 150 break; 151 case TYPE_WIRED_HEADSET: 152 case TYPE_WIRED_HEADPHONES: 153 mType = MediaDeviceType.TYPE_3POINT5_MM_AUDIO_DEVICE; 154 break; 155 case TYPE_USB_DEVICE: 156 case TYPE_USB_HEADSET: 157 case TYPE_USB_ACCESSORY: 158 case TYPE_DOCK: 159 case TYPE_HDMI: 160 case TYPE_HDMI_ARC: 161 case TYPE_HDMI_EARC: 162 mType = MediaDeviceType.TYPE_USB_C_AUDIO_DEVICE; 163 break; 164 case TYPE_HEARING_AID: 165 case TYPE_BLUETOOTH_A2DP: 166 case TYPE_BLE_HEADSET: 167 mType = MediaDeviceType.TYPE_BLUETOOTH_DEVICE; 168 break; 169 case TYPE_REMOTE_AUDIO_VIDEO_RECEIVER: 170 mType = MediaDeviceType.TYPE_REMOTE_AUDIO_VIDEO_RECEIVER; 171 break; 172 case TYPE_UNKNOWN: 173 case TYPE_REMOTE_TV: 174 case TYPE_REMOTE_SPEAKER: 175 default: 176 mType = MediaDeviceType.TYPE_CAST_DEVICE; 177 break; 178 } 179 } 180 initDeviceRecord()181 void initDeviceRecord() { 182 ConnectionRecordManager.getInstance().fetchLastSelectedDevice(mContext); 183 mConnectedRecord = ConnectionRecordManager.getInstance().fetchConnectionRecord(mContext, 184 getId()); 185 } 186 getRangeZone()187 public @NearbyDevice.RangeZone int getRangeZone() { 188 return mRangeZone; 189 } 190 setRangeZone(@earbyDevice.RangeZone int rangeZone)191 public void setRangeZone(@NearbyDevice.RangeZone int rangeZone) { 192 mRangeZone = rangeZone; 193 } 194 195 /** 196 * Get name from MediaDevice. 197 * 198 * @return name of MediaDevice. 199 */ getName()200 public abstract String getName(); 201 202 /** 203 * Get summary from MediaDevice. 204 * 205 * @return summary of MediaDevice. 206 */ getSummary()207 public abstract String getSummary(); 208 209 /** 210 * Get summary from MediaDevice for TV with low batter states in a different color if 211 * applicable. 212 * 213 * @param lowBatteryColorRes Color resource for the part of the CharSequence that describes a 214 * low battery state. 215 */ getSummaryForTv(int lowBatteryColorRes)216 public CharSequence getSummaryForTv(int lowBatteryColorRes) { 217 return getSummary(); 218 } 219 220 /** 221 * Get icon of MediaDevice. 222 * 223 * @return drawable of icon. 224 */ getIcon()225 public abstract Drawable getIcon(); 226 227 /** 228 * Get icon of MediaDevice without background. 229 * 230 * @return drawable of icon 231 */ getIconWithoutBackground()232 public abstract Drawable getIconWithoutBackground(); 233 234 /** 235 * Get unique ID that represent MediaDevice 236 * 237 * @return unique id of MediaDevice 238 */ getId()239 public abstract String getId(); 240 241 /** 242 * Get selection behavior of device 243 * 244 * @return selection behavior of device 245 */ 246 @SelectionBehavior getSelectionBehavior()247 public int getSelectionBehavior() { 248 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && mItem != null 249 ? mItem.getSelectionBehavior() : SELECTION_BEHAVIOR_TRANSFER; 250 } 251 252 /** 253 * Checks if device is has subtext 254 * 255 * @return true if device has subtext 256 */ hasSubtext()257 public boolean hasSubtext() { 258 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE 259 && mItem != null 260 && mItem.getSubText() != SUBTEXT_NONE; 261 } 262 263 /** 264 * Get subtext of device 265 * 266 * @return subtext of device 267 */ 268 @RouteListingPreference.Item.SubText getSubtext()269 public int getSubtext() { 270 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && mItem != null 271 ? mItem.getSubText() : SUBTEXT_NONE; 272 } 273 274 /** 275 * Returns subtext string for current route. 276 * 277 * @return subtext string for this route 278 */ getSubtextString()279 public String getSubtextString() { 280 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && mItem != null 281 ? Api34Impl.composeSubtext(mItem, mContext) : null; 282 } 283 284 /** 285 * Checks if device has ongoing shared session, which allow user to join 286 * 287 * @return true if device has ongoing session 288 */ hasOngoingSession()289 public boolean hasOngoingSession() { 290 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE 291 && Api34Impl.hasOngoingSession(mItem); 292 } 293 294 /** 295 * Checks if device is the host for ongoing shared session, which allow user to adjust volume 296 * 297 * @return true if device is the host for ongoing shared session 298 */ isHostForOngoingSession()299 public boolean isHostForOngoingSession() { 300 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE 301 && Api34Impl.isHostForOngoingSession(mItem); 302 } 303 304 /** 305 * Checks if device is suggested device from application 306 * 307 * @return true if device is suggested device 308 */ isSuggestedDevice()309 public boolean isSuggestedDevice() { 310 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE 311 && Api34Impl.isSuggestedDevice(mItem); 312 } 313 setConnectedRecord()314 void setConnectedRecord() { 315 mConnectedRecord++; 316 ConnectionRecordManager.getInstance().setConnectionRecord(mContext, getId(), 317 mConnectedRecord); 318 } 319 320 /** 321 * According the MediaDevice type to check whether we are connected to this MediaDevice. 322 * 323 * @return Whether it is connected. 324 */ isConnected()325 public abstract boolean isConnected(); 326 327 /** 328 * Get max volume from MediaDevice. 329 * 330 * @return max volume. 331 */ getMaxVolume()332 public int getMaxVolume() { 333 if (mRouteInfo == null) { 334 Log.w(TAG, "Unable to get max volume. RouteInfo is empty"); 335 return 0; 336 } 337 return mRouteInfo.getVolumeMax(); 338 } 339 340 /** 341 * Get current volume from MediaDevice. 342 * 343 * @return current volume. 344 */ getCurrentVolume()345 public int getCurrentVolume() { 346 if (mRouteInfo == null) { 347 Log.w(TAG, "Unable to get current volume. RouteInfo is empty"); 348 return 0; 349 } 350 return mRouteInfo.getVolume(); 351 } 352 353 /** 354 * Get application package name. 355 * 356 * @return package name. 357 */ getClientPackageName()358 public String getClientPackageName() { 359 if (mRouteInfo == null) { 360 Log.w(TAG, "Unable to get client package name. RouteInfo is empty"); 361 return null; 362 } 363 return mRouteInfo.getClientPackageName(); 364 } 365 366 /** 367 * Check if the device is Bluetooth LE Audio device. 368 * 369 * @return true if the RouteInfo equals TYPE_BLE_HEADSET. 370 */ 371 // MediaRoute2Info.getType was made public on API 34, but exists since API 30. 372 @SuppressWarnings("NewApi") isBLEDevice()373 public boolean isBLEDevice() { 374 return mRouteInfo.getType() == TYPE_BLE_HEADSET; 375 } 376 377 /** 378 * Get application label from MediaDevice. 379 * 380 * @return application label. 381 */ getDeviceType()382 public int getDeviceType() { 383 return mType; 384 } 385 386 /** 387 * Checks if route's volume is fixed, if true, we should disable volume control for the device. 388 * 389 * @return route for this device is fixed. 390 */ 391 @SuppressLint("NewApi") isVolumeFixed()392 public boolean isVolumeFixed() { 393 if (mRouteInfo == null) { 394 Log.w(TAG, "RouteInfo is empty, regarded as volume fixed."); 395 return true; 396 } 397 return mRouteInfo.getVolumeHandling() == MediaRoute2Info.PLAYBACK_VOLUME_FIXED; 398 } 399 400 /** 401 * Set current device's state 402 */ setState(@ocalMediaManager.MediaDeviceState int state)403 public void setState(@LocalMediaManager.MediaDeviceState int state) { 404 mState = state; 405 } 406 407 /** 408 * Get current device's state 409 * 410 * @return state of device 411 */ getState()412 public @LocalMediaManager.MediaDeviceState int getState() { 413 return mState; 414 } 415 416 /** 417 * Rules: 418 * 1. If there is one of the connected devices identified as a carkit or fast pair device, 419 * the fast pair device will be always on the first of the device list and carkit will be 420 * second. Rule 2 and Rule 3 can’t overrule this rule. 421 * 2. For devices without any usage data yet 422 * WiFi device group sorted by alphabetical order + BT device group sorted by alphabetical 423 * order + phone speaker 424 * 3. For devices with usage record. 425 * The most recent used one + device group with usage info sorted by how many times the 426 * device has been used. 427 * 4. The order is followed below rule: 428 * 1. Phone 429 * 2. USB-C audio device 430 * 3. 3.5 mm audio device 431 * 4. Bluetooth device 432 * 5. Cast device 433 * 6. Cast group device 434 * 435 * So the device list will look like 5 slots ranked as below. 436 * Rule 4 + Rule 1 + the most recently used device + Rule 3 + Rule 2 437 * Any slot could be empty. And available device will belong to one of the slots. 438 * 439 * @return a negative integer, zero, or a positive integer 440 * as this object is less than, equal to, or greater than the specified object. 441 */ 442 @Override compareTo(MediaDevice another)443 public int compareTo(MediaDevice another) { 444 if (another == null) { 445 return -1; 446 } 447 // Check Bluetooth device is have same connection state 448 if (isConnected() ^ another.isConnected()) { 449 if (isConnected()) { 450 return -1; 451 } else { 452 return 1; 453 } 454 } 455 456 if (getState() == STATE_SELECTED) { 457 return -1; 458 } else if (another.getState() == STATE_SELECTED) { 459 return 1; 460 } 461 462 if (mType == another.mType) { 463 // Check device is muting expected device 464 if (isMutingExpectedDevice()) { 465 return -1; 466 } else if (another.isMutingExpectedDevice()) { 467 return 1; 468 } 469 470 // Check fast pair device 471 if (isFastPairDevice()) { 472 return -1; 473 } else if (another.isFastPairDevice()) { 474 return 1; 475 } 476 477 // Check carkit 478 if (isCarKitDevice()) { 479 return -1; 480 } else if (another.isCarKitDevice()) { 481 return 1; 482 } 483 484 // Both devices have same connection status and type, compare the range zone 485 if (NearbyDevice.compareRangeZones(getRangeZone(), another.getRangeZone()) != 0) { 486 return NearbyDevice.compareRangeZones(getRangeZone(), another.getRangeZone()); 487 } 488 489 // Set last used device at the first item 490 final String lastSelectedDevice = ConnectionRecordManager.getInstance() 491 .getLastSelectedDevice(); 492 if (TextUtils.equals(lastSelectedDevice, getId())) { 493 return -1; 494 } else if (TextUtils.equals(lastSelectedDevice, another.getId())) { 495 return 1; 496 } 497 // Sort by how many times the device has been used if there is usage record 498 if ((mConnectedRecord != another.mConnectedRecord) 499 && (another.mConnectedRecord > 0 || mConnectedRecord > 0)) { 500 return (another.mConnectedRecord - mConnectedRecord); 501 } 502 503 // Both devices have never been used 504 // To devices with the same type, sort by alphabetical order 505 final String s1 = getName(); 506 final String s2 = another.getName(); 507 return s1.compareToIgnoreCase(s2); 508 } else { 509 // Both devices have never been used, the priority is: 510 // 1. Phone 511 // 2. USB-C audio device 512 // 3. 3.5 mm audio device 513 // 4. Bluetooth device 514 // 5. Cast device 515 // 6. Cast group device 516 return mType < another.mType ? -1 : 1; 517 } 518 } 519 520 /** 521 * Gets the supported features of the route. 522 */ getFeatures()523 public List<String> getFeatures() { 524 if (mRouteInfo == null) { 525 Log.w(TAG, "Unable to get features. RouteInfo is empty"); 526 return new ArrayList<>(); 527 } 528 return mRouteInfo.getFeatures(); 529 } 530 531 /** 532 * Check if it is CarKit device 533 * @return true if it is CarKit device 534 */ isCarKitDevice()535 protected boolean isCarKitDevice() { 536 return false; 537 } 538 539 /** 540 * Check if it is FastPair device 541 * @return {@code true} if it is FastPair device, otherwise return {@code false} 542 */ isFastPairDevice()543 protected boolean isFastPairDevice() { 544 return false; 545 } 546 547 /** 548 * Check if it is muting expected device 549 * @return {@code true} if it is muting expected device, otherwise return {@code false} 550 */ isMutingExpectedDevice()551 public boolean isMutingExpectedDevice() { 552 return false; 553 } 554 555 @Override equals(Object obj)556 public boolean equals(Object obj) { 557 if (!(obj instanceof MediaDevice)) { 558 return false; 559 } 560 final MediaDevice otherDevice = (MediaDevice) obj; 561 return otherDevice.getId().equals(getId()); 562 } 563 564 @RequiresApi(34) 565 private static class Api34Impl { 566 @DoNotInline isHostForOngoingSession(RouteListingPreference.Item item)567 static boolean isHostForOngoingSession(RouteListingPreference.Item item) { 568 int flags = item != null ? item.getFlags() : 0; 569 return (flags & FLAG_ONGOING_SESSION) != 0 570 && (flags & FLAG_ONGOING_SESSION_MANAGED) != 0; 571 } 572 573 @DoNotInline isSuggestedDevice(RouteListingPreference.Item item)574 static boolean isSuggestedDevice(RouteListingPreference.Item item) { 575 return item != null && (item.getFlags() & FLAG_SUGGESTED) != 0; 576 } 577 578 @DoNotInline hasOngoingSession(RouteListingPreference.Item item)579 static boolean hasOngoingSession(RouteListingPreference.Item item) { 580 return item != null && (item.getFlags() & FLAG_ONGOING_SESSION) != 0; 581 } 582 583 @DoNotInline composeSubtext(RouteListingPreference.Item item, Context context)584 static String composeSubtext(RouteListingPreference.Item item, Context context) { 585 switch (item.getSubText()) { 586 case SUBTEXT_ERROR_UNKNOWN: 587 return context.getString(R.string.media_output_status_unknown_error); 588 case SUBTEXT_SUBSCRIPTION_REQUIRED: 589 return context.getString(R.string.media_output_status_require_premium); 590 case SUBTEXT_DOWNLOADED_CONTENT_ROUTING_DISALLOWED: 591 return context.getString(R.string.media_output_status_not_support_downloads); 592 case SUBTEXT_AD_ROUTING_DISALLOWED: 593 return context.getString(R.string.media_output_status_try_after_ad); 594 case SUBTEXT_DEVICE_LOW_POWER: 595 return context.getString(R.string.media_output_status_device_in_low_power_mode); 596 case SUBTEXT_UNAUTHORIZED: 597 return context.getString(R.string.media_output_status_unauthorized); 598 case SUBTEXT_TRACK_UNSUPPORTED: 599 return context.getString(R.string.media_output_status_track_unsupported); 600 case SUBTEXT_CUSTOM: 601 return (String) item.getCustomSubtextMessage(); 602 } 603 return ""; 604 } 605 } 606 } 607