• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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