1 /* 2 * Copyright (C) 2023 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.systemui.tv.media; 18 19 import android.content.Context; 20 import android.content.Intent; 21 import android.content.res.Resources; 22 import android.graphics.drawable.Drawable; 23 import android.media.MediaRoute2Info; 24 import android.net.Uri; 25 import android.os.Bundle; 26 import android.text.Annotation; 27 import android.text.Spannable; 28 import android.text.SpannableString; 29 import android.text.SpannedString; 30 import android.text.TextUtils; 31 import android.util.Log; 32 import android.view.KeyEvent; 33 import android.view.LayoutInflater; 34 import android.view.View; 35 import android.view.ViewGroup; 36 import android.view.accessibility.AccessibilityManager; 37 import android.widget.ImageButton; 38 import android.widget.ImageView; 39 import android.widget.RadioButton; 40 import android.widget.TextView; 41 42 import androidx.annotation.NonNull; 43 import androidx.recyclerview.widget.RecyclerView; 44 45 import com.android.settingslib.media.BluetoothMediaDevice; 46 import com.android.settingslib.media.LocalMediaManager; 47 import com.android.settingslib.media.MediaDevice; 48 import com.android.settingslib.media.MediaDevice.MediaDeviceType; 49 import com.android.systemui.media.dialog.MediaItem; 50 import com.android.systemui.tv.media.settings.CenteredImageSpan; 51 import com.android.systemui.tv.media.settings.ControlWidget; 52 import com.android.systemui.tv.res.R; 53 54 import java.util.Arrays; 55 import java.util.List; 56 import java.util.concurrent.CopyOnWriteArrayList; 57 58 /** 59 * Adapter for showing the {@link MediaItem}s in the {@link TvMediaOutputDialogActivity}. 60 */ 61 public class TvMediaOutputAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { 62 63 private static final String TAG = TvMediaOutputAdapter.class.getSimpleName(); 64 private static final boolean DEBUG = false; 65 66 private final TvMediaOutputController mMediaOutputController; 67 private final PanelCallback mCallback; 68 private final Context mContext; 69 protected List<MediaItem> mMediaItemList = new CopyOnWriteArrayList<>(); 70 71 private final AccessibilityManager mA11yManager; 72 73 private final CharSequence mTooltipText; 74 private String mSavedDeviceId; 75 76 private final boolean mIsRtl; 77 TvMediaOutputAdapter(Context context, TvMediaOutputController mediaOutputController, PanelCallback callback)78 TvMediaOutputAdapter(Context context, TvMediaOutputController mediaOutputController, 79 PanelCallback callback) { 80 mContext = context; 81 mMediaOutputController = mediaOutputController; 82 mCallback = callback; 83 84 mA11yManager = context.getSystemService(AccessibilityManager.class); 85 86 Resources res = mContext.getResources(); 87 mIsRtl = res.getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; 88 mTooltipText = createTooltipText(); 89 90 setHasStableIds(true); 91 } 92 93 @Override getItemViewType(int position)94 public int getItemViewType(int position) { 95 if (position >= mMediaItemList.size()) { 96 Log.e(TAG, "Incorrect position for item type: " + position); 97 return MediaItem.MediaItemType.TYPE_GROUP_DIVIDER; 98 } 99 return mMediaItemList.get(position).getMediaItemType(); 100 } 101 102 @Override onCreateViewHolder(ViewGroup parent, int viewType)103 public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { 104 View mHolderView = LayoutInflater.from(mContext) 105 .inflate(MediaItem.getMediaLayoutId(viewType), parent, false); 106 107 switch (viewType) { 108 case MediaItem.MediaItemType.TYPE_GROUP_DIVIDER: 109 return new DividerViewHolder(mHolderView); 110 case MediaItem.MediaItemType.TYPE_PAIR_NEW_DEVICE: 111 case MediaItem.MediaItemType.TYPE_DEVICE: 112 return new DeviceViewHolder(mHolderView); 113 default: 114 Log.e(TAG, "unknown viewType: " + viewType); 115 return new DeviceViewHolder(mHolderView); 116 } 117 } 118 119 @Override onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position)120 public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { 121 if (position >= getItemCount()) { 122 Log.e(TAG, "Tried to bind at position > list size (" + getItemCount() + ")"); 123 } 124 125 MediaItem currentMediaItem = mMediaItemList.get(position); 126 switch (currentMediaItem.getMediaItemType()) { 127 case MediaItem.MediaItemType.TYPE_GROUP_DIVIDER -> 128 ((DividerViewHolder) viewHolder).onBind(currentMediaItem.getTitle()); 129 case MediaItem.MediaItemType.TYPE_PAIR_NEW_DEVICE -> 130 ((DeviceViewHolder) viewHolder).onBindNewDevice(); 131 case MediaItem.MediaItemType.TYPE_DEVICE -> ((DeviceViewHolder) viewHolder).onBind( 132 currentMediaItem.getMediaDevice().get(), position); 133 default -> Log.d(TAG, "Incorrect position: " + position); 134 } 135 } 136 137 @Override getItemCount()138 public int getItemCount() { 139 return mMediaItemList.size(); 140 } 141 142 /** 143 * Returns position of the MediaDevice with the saved device id. 144 */ getFocusPosition()145 protected int getFocusPosition() { 146 if (DEBUG) Log.d(TAG, "getFocusPosition, deviceId: " + mSavedDeviceId); 147 if (mSavedDeviceId == null) { 148 return 0; 149 } 150 for (int i = 0; i < mMediaItemList.size(); i++) { 151 MediaItem item = mMediaItemList.get(i); 152 if (item.getMediaDevice().isPresent()) { 153 if (item.getMediaDevice().get().getId().equals(mSavedDeviceId)) { 154 mSavedDeviceId = null; 155 return i; 156 } 157 } 158 } 159 return 0; 160 } 161 162 /** 163 * Replaces the dpad action with an icon. 164 */ createTooltipText()165 private CharSequence createTooltipText() { 166 Resources res = mContext.getResources(); 167 final SpannedString tooltipText = (SpannedString) res.getText( 168 R.string.audio_device_settings_tooltip); 169 final SpannableString spannableString = new SpannableString(tooltipText); 170 Arrays.stream(tooltipText.getSpans(0, tooltipText.length(), Annotation.class)).findFirst() 171 .ifPresent(annotation -> { 172 final Drawable icon = 173 res.getDrawable(R.drawable.dpad_right, mContext.getTheme()); 174 icon.setLayoutDirection( 175 mContext.getResources().getConfiguration().getLayoutDirection()); 176 icon.mutate(); 177 icon.setBounds(0, 0, icon.getIntrinsicWidth(), icon.getIntrinsicHeight()); 178 spannableString.setSpan(new CenteredImageSpan(icon), 179 tooltipText.getSpanStart(annotation), 180 tooltipText.getSpanEnd(annotation), 181 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 182 }); 183 184 return spannableString; 185 } 186 187 @Override getItemId(int position)188 public long getItemId(int position) { 189 MediaItem item = mMediaItemList.get(position); 190 if (item.getMediaDevice().isPresent()) { 191 return item.getMediaDevice().get().getId().hashCode(); 192 } 193 if (item.getMediaItemType() == MediaItem.MediaItemType.TYPE_GROUP_DIVIDER) { 194 if (item.getTitle() == null || item.getTitle().isEmpty()) { 195 return MediaItem.MediaItemType.TYPE_GROUP_DIVIDER; 196 } 197 return item.getTitle().hashCode(); 198 } 199 return item.getMediaItemType(); 200 } 201 updateItems()202 public void updateItems() { 203 mMediaItemList.clear(); 204 mMediaItemList.addAll(mMediaOutputController.getMediaItemList()); 205 if (DEBUG) { 206 Log.d(TAG, "updateItems"); 207 for (MediaItem mediaItem : mMediaItemList) { 208 Log.d(TAG, mediaItem.toString()); 209 } 210 } 211 notifyDataSetChanged(); 212 } 213 214 private class DeviceViewHolder extends RecyclerView.ViewHolder { 215 final ImageView mIcon; 216 final TextView mTitle; 217 final TextView mSubtitle; 218 final RadioButton mRadioButton; 219 final ImageButton mA11ySettingsButton; 220 final OutputDeviceControlWidget mWidget; 221 MediaDevice mMediaDevice; 222 DeviceViewHolder(View itemView)223 DeviceViewHolder(View itemView) { 224 super(itemView); 225 mIcon = itemView.requireViewById(R.id.media_output_item_icon); 226 mTitle = itemView.requireViewById(R.id.media_dialog_item_title); 227 mSubtitle = itemView.requireViewById(R.id.media_dialog_item_subtitle); 228 mRadioButton = itemView.requireViewById(R.id.media_dialog_radio_button); 229 230 mWidget = itemView.requireViewById(R.id.media_dialog_device_widget); 231 mA11ySettingsButton = itemView.requireViewById(R.id.media_dialog_item_a11y_settings); 232 } 233 onBind(MediaDevice mediaDevice, int position)234 void onBind(MediaDevice mediaDevice, int position) { 235 mMediaDevice = mediaDevice; 236 // Title 237 mTitle.setText(mediaDevice.getName()); 238 239 // Subtitle 240 setSummary(mediaDevice); 241 242 // Icon 243 Drawable icon; 244 if (mediaDevice.getState() 245 == LocalMediaManager.MediaDeviceState.STATE_CONNECTING_FAILED) { 246 icon = 247 mContext.getDrawable( 248 com.android.systemui.res.R.drawable.media_output_status_failed); 249 } else { 250 icon = mediaDevice.getIconWithoutBackground(); 251 } 252 if (icon == null) { 253 if (DEBUG) Log.d(TAG, "Using default icon for " + mediaDevice); 254 icon = mContext.getDrawable( 255 com.android.settingslib.R.drawable.ic_media_speaker_device); 256 } 257 mIcon.setImageDrawable(icon); 258 259 mRadioButton.setVisibility(mediaDevice.isConnected() ? View.VISIBLE : View.GONE); 260 mRadioButton.setChecked(isCurrentlyConnected(mediaDevice)); 261 262 mWidget.setOnFocusChangeListener((view, focused) -> { 263 setSummary(mediaDevice); 264 mTitle.setSelected(focused); 265 mSubtitle.setSelected(focused); 266 }); 267 268 mWidget.setOnClickListener(v -> transferOutput(mediaDevice)); 269 270 String baseUri = getBaseUriForDevice(mContext, mMediaDevice); 271 boolean hasSettings = baseUri != null && !baseUri.isEmpty(); 272 273 if (hasSettings) { 274 if (mA11yManager.isEnabled()) { 275 mA11ySettingsButton.setVisibility(View.VISIBLE); 276 mA11ySettingsButton.setContentDescription( 277 mContext.getString(R.string.audio_device_settings_content_description, 278 mediaDevice.getName())); 279 mA11ySettingsButton.setOnClickListener((view) -> { 280 openDeviceSettings(baseUri); 281 }); 282 } else { 283 ControlWidget.TooltipConfig toolTipConfig = new ControlWidget.TooltipConfig(); 284 toolTipConfig.setShouldShowTooltip(true); 285 toolTipConfig.setTooltipText(mTooltipText); 286 mWidget.setTooltipConfig(toolTipConfig); 287 288 mWidget.setOnKeyListener( 289 (v, keyCode, event) -> { 290 if (event.getAction() != KeyEvent.ACTION_UP) { 291 return false; 292 } 293 int dpadArrow = mIsRtl ? 294 KeyEvent.KEYCODE_DPAD_LEFT : KeyEvent.KEYCODE_DPAD_RIGHT; 295 if (mMediaDevice != null 296 && (keyCode == dpadArrow 297 || (keyCode == KeyEvent.KEYCODE_DPAD_CENTER 298 && event.isLongPress()))) { 299 300 return openDeviceSettings(baseUri); 301 } 302 return false; 303 }); 304 305 mA11ySettingsButton.setVisibility(View.GONE); 306 } 307 } else { 308 mA11ySettingsButton.setVisibility(View.GONE); 309 } 310 } 311 openDeviceSettings(@onNull String baseUri)312 private boolean openDeviceSettings(@NonNull String baseUri) { 313 Uri uri = Uri.parse(baseUri); 314 if (mMediaDevice.getDeviceType() 315 == MediaDeviceType.TYPE_BLUETOOTH_DEVICE) { 316 uri = 317 Uri.withAppendedPath( 318 uri, 319 ((BluetoothMediaDevice) mMediaDevice) 320 .getCachedDevice() 321 .getAddress()); 322 } 323 324 mSavedDeviceId = mMediaDevice.getId(); 325 mCallback.openDeviceSettings( 326 uri.toString(), 327 mTitle.getText(), 328 getSummary(mMediaDevice, /* focused= */ false), 329 mMediaDevice.getId()); 330 331 return true; 332 } 333 setSummary(MediaDevice mediaDevice)334 private void setSummary(MediaDevice mediaDevice) { 335 CharSequence summary = getSummary(mediaDevice, mWidget.hasFocus()); 336 if (mediaDevice.getDeviceType() == MediaDeviceType.TYPE_PHONE_DEVICE 337 && mContext.getResources().getBoolean( 338 com.android.systemui.tv.res.R.bool. 339 config_audioOutputInternalSpeakerGroupedWithSpdif)) { 340 mSubtitle.setText(mContext.getResources().getString( 341 R.string.media_output_internal_speaker_spdif_subtitle)); 342 } else { 343 mSubtitle.setText(summary); 344 } 345 mSubtitle.setVisibility(summary == null || summary.isEmpty() 346 ? View.GONE : View.VISIBLE); 347 } 348 getSummary(MediaDevice mediaDevice, boolean focused)349 private CharSequence getSummary(MediaDevice mediaDevice, boolean focused) { 350 if (mediaDevice.getState() 351 == LocalMediaManager.MediaDeviceState.STATE_CONNECTING_FAILED) { 352 return mContext.getString( 353 com.android.systemui.res.R.string.media_output_dialog_connect_failed); 354 } else { 355 return mediaDevice.getSummaryForTv(focused 356 ? R.color.media_dialog_low_battery_focused 357 : R.color.media_dialog_low_battery_unfocused); 358 } 359 } 360 transferOutput(MediaDevice mediaDevice)361 private void transferOutput(MediaDevice mediaDevice) { 362 if (mMediaOutputController.isAnyDeviceTransferring()) { 363 // Don't interrupt ongoing transfer 364 return; 365 } 366 if (isCurrentlyConnected(mediaDevice)) { 367 if (DEBUG) Log.d(TAG, "Device is already selected as the active output"); 368 return; 369 } 370 mMediaOutputController.setTemporaryAllowListExceptionIfNeeded(mediaDevice); 371 mMediaOutputController.connectDevice(mediaDevice); 372 mediaDevice.setState(LocalMediaManager.MediaDeviceState.STATE_CONNECTING); 373 notifyDataSetChanged(); 374 } 375 376 /** 377 * The single currentConnected device or the only selected device 378 */ isCurrentlyConnected(MediaDevice device)379 boolean isCurrentlyConnected(MediaDevice device) { 380 return TextUtils.equals(device.getId(), 381 mMediaOutputController.getCurrentConnectedMediaDevice().getId()) 382 || (mMediaOutputController.getSelectedMediaDevice().size() == 1 383 && isDeviceIncluded(mMediaOutputController.getSelectedMediaDevice(), device)); 384 } 385 onBindNewDevice()386 void onBindNewDevice() { 387 mIcon.setImageResource(com.android.systemui.res.R.drawable.ic_add); 388 mTitle.setText(R.string.media_output_dialog_pairing_new); 389 mSubtitle.setVisibility(View.GONE); 390 mRadioButton.setVisibility(View.GONE); 391 392 mWidget.setOnClickListener(v -> launchBluetoothSettings()); 393 } 394 launchBluetoothSettings()395 private void launchBluetoothSettings() { 396 mCallback.dismissDialog(); 397 398 String uri = mMediaOutputController.getBluetoothSettingsSliceUri(); 399 if (uri == null) { 400 return; 401 } 402 403 Intent bluetoothIntent = new Intent("android.settings.SLICE_SETTINGS"); 404 Bundle extra = new Bundle(); 405 extra.putString("slice_uri", uri); 406 bluetoothIntent.putExtras(extra); 407 bluetoothIntent.addFlags( 408 Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); 409 mContext.startActivity(bluetoothIntent); 410 } 411 isDeviceIncluded(List<MediaDevice> deviceList, MediaDevice targetDevice)412 private boolean isDeviceIncluded(List<MediaDevice> deviceList, MediaDevice targetDevice) { 413 for (MediaDevice device : deviceList) { 414 if (TextUtils.equals(device.getId(), targetDevice.getId())) { 415 return true; 416 } 417 } 418 return false; 419 } 420 getBaseUriForDevice(Context context, MediaDevice device)421 static String getBaseUriForDevice(Context context, MediaDevice device) { 422 int resourceId; 423 424 int deviceType = device.getDeviceType(); 425 426 if (deviceType == MediaDeviceType.TYPE_USB_C_AUDIO_DEVICE) { 427 int routeType = device.getRouteType(); 428 switch (routeType) { 429 case MediaRoute2Info.TYPE_HDMI: 430 resourceId = R.string.audio_output_hdmi_slice_uri; 431 break; 432 case MediaRoute2Info.TYPE_HDMI_ARC: 433 case MediaRoute2Info.TYPE_HDMI_EARC: 434 resourceId = R.string.audio_output_hdmi_e_arc_slice_uri; 435 break; 436 case MediaRoute2Info.TYPE_USB_HEADSET: 437 case MediaRoute2Info.TYPE_USB_DEVICE: 438 case MediaRoute2Info.TYPE_USB_ACCESSORY: 439 resourceId = R.string.audio_output_usb_slice_uri; 440 break; 441 default: 442 return null; 443 } 444 } else { 445 switch (deviceType) { 446 case MediaDeviceType.TYPE_PHONE_DEVICE: 447 resourceId = R.string.audio_output_builtin_speaker_slice_uri; 448 break; 449 case MediaDeviceType.TYPE_BLUETOOTH_DEVICE: 450 resourceId = R.string.audio_output_bluetooth_slice_uri; 451 break; 452 case MediaDeviceType.TYPE_3POINT5_MM_AUDIO_DEVICE: 453 resourceId = R.string.audio_output_wired_headphone_slice_uri; 454 break; 455 case MediaDeviceType.TYPE_CAST_DEVICE: 456 resourceId = R.string.audio_output_cast_device_slice_uri; 457 break; 458 case MediaDeviceType.TYPE_CAST_GROUP_DEVICE: 459 resourceId = R.string.audio_output_cast_group_slice_uri; 460 break; 461 case MediaDeviceType.TYPE_REMOTE_AUDIO_VIDEO_RECEIVER: 462 resourceId = R.string.audio_output_remote_avr_slice_uri; 463 break; 464 default: 465 return null; 466 } 467 } 468 469 return context.getString(resourceId); 470 } 471 } 472 473 private static class DividerViewHolder extends RecyclerView.ViewHolder { 474 final TextView mHeaderText; 475 final View mDividerLine; 476 DividerViewHolder(@onNull View itemView)477 DividerViewHolder(@NonNull View itemView) { 478 super(itemView); 479 mHeaderText = itemView.requireViewById(R.id.media_output_group_header); 480 mDividerLine = itemView.requireViewById(R.id.media_output_divider_line); 481 } 482 onBind(String groupDividerTitle)483 void onBind(String groupDividerTitle) { 484 boolean hasText = groupDividerTitle != null && !groupDividerTitle.isEmpty(); 485 mHeaderText.setVisibility(hasText ? View.VISIBLE : View.GONE); 486 mDividerLine.setVisibility(hasText ? View.GONE : View.VISIBLE); 487 if (hasText) { 488 mHeaderText.setText(groupDividerTitle); 489 } 490 } 491 492 } 493 494 interface PanelCallback { openDeviceSettings(String uri, CharSequence title, CharSequence subtitle, String id)495 void openDeviceSettings(String uri, CharSequence title, CharSequence subtitle, String id); 496 dismissDialog()497 void dismissDialog(); 498 } 499 } 500