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