1 /* 2 * Copyright (C) 2020 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.media.dialog; 18 19 import static com.android.settingslib.media.MediaDevice.SelectionBehavior.SELECTION_BEHAVIOR_GO_TO_APP; 20 import static com.android.settingslib.media.MediaDevice.SelectionBehavior.SELECTION_BEHAVIOR_NONE; 21 import static com.android.settingslib.media.MediaDevice.SelectionBehavior.SELECTION_BEHAVIOR_TRANSFER; 22 import static com.android.media.flags.Flags.enableOutputSwitcherRedesign; 23 24 import android.content.Context; 25 import android.graphics.drawable.Drawable; 26 import android.text.TextUtils; 27 import android.util.Log; 28 import android.view.View; 29 30 import androidx.annotation.DoNotInline; 31 import androidx.annotation.NonNull; 32 import androidx.annotation.Nullable; 33 import androidx.annotation.RequiresApi; 34 import androidx.recyclerview.widget.RecyclerView; 35 36 import com.android.internal.annotations.VisibleForTesting; 37 import com.android.media.flags.Flags; 38 import com.android.settingslib.media.LocalMediaManager.MediaDeviceState; 39 import com.android.settingslib.media.MediaDevice; 40 import com.android.systemui.res.R; 41 42 import java.util.List; 43 import java.util.concurrent.CopyOnWriteArrayList; 44 45 /** 46 * A parent RecyclerView adapter for the media output dialog device list. This class doesn't 47 * manipulate the layout directly. 48 */ 49 public abstract class MediaOutputAdapterBase extends RecyclerView.Adapter<RecyclerView.ViewHolder> { OngoingSessionStatus(boolean host)50 public record OngoingSessionStatus(boolean host) {} 51 GroupStatus(Boolean selected, Boolean deselectable)52 public record GroupStatus(Boolean selected, Boolean deselectable) {} 53 54 public enum ConnectionState { 55 CONNECTED, 56 CONNECTING, 57 DISCONNECTED, 58 } 59 60 protected final MediaSwitchingController mController; 61 private int mCurrentActivePosition; 62 private boolean mIsDragging; 63 private static final String TAG = "MediaOutputAdapterBase"; 64 private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); 65 protected final List<MediaItem> mMediaItemList = new CopyOnWriteArrayList<>(); 66 private boolean mShouldGroupSelectedMediaItems = Flags.enableOutputSwitcherDeviceGrouping(); 67 MediaOutputAdapterBase(MediaSwitchingController controller)68 public MediaOutputAdapterBase(MediaSwitchingController controller) { 69 mController = controller; 70 mCurrentActivePosition = -1; 71 mIsDragging = false; 72 setHasStableIds(true); 73 } 74 isCurrentlyConnected(MediaDevice device)75 boolean isCurrentlyConnected(MediaDevice device) { 76 return TextUtils.equals(device.getId(), 77 mController.getCurrentConnectedMediaDevice().getId()) 78 || (mController.getSelectedMediaDevice().size() == 1 79 && isDeviceIncluded(mController.getSelectedMediaDevice(), device)); 80 } 81 isDeviceIncluded(List<MediaDevice> deviceList, MediaDevice targetDevice)82 boolean isDeviceIncluded(List<MediaDevice> deviceList, MediaDevice targetDevice) { 83 for (MediaDevice device : deviceList) { 84 if (TextUtils.equals(device.getId(), targetDevice.getId())) { 85 return true; 86 } 87 } 88 return false; 89 } 90 isDragging()91 boolean isDragging() { 92 return mIsDragging; 93 } 94 setIsDragging(boolean isDragging)95 void setIsDragging(boolean isDragging) { 96 mIsDragging = isDragging; 97 } 98 getCurrentActivePosition()99 int getCurrentActivePosition() { 100 return mCurrentActivePosition; 101 } 102 103 /** Refreshes the RecyclerView dataset and forces re-render. */ updateItems()104 public void updateItems() { 105 mMediaItemList.clear(); 106 mMediaItemList.addAll(mController.getMediaItemList()); 107 if (mShouldGroupSelectedMediaItems) { 108 if (mController.getSelectedMediaDevice().size() == 1) { 109 // Don't group devices if initially there isn't more than one selected. 110 mShouldGroupSelectedMediaItems = false; 111 } 112 } 113 notifyDataSetChanged(); 114 } 115 116 @Override getItemId(int position)117 public long getItemId(int position) { 118 if (position >= mMediaItemList.size()) { 119 Log.d(TAG, "Incorrect position for item id: " + position); 120 return position; 121 } 122 MediaItem currentMediaItem = mMediaItemList.get(position); 123 return currentMediaItem.getMediaDevice().isPresent() 124 ? currentMediaItem.getMediaDevice().get().getId().hashCode() 125 : position; 126 } 127 128 @Override getItemViewType(int position)129 public int getItemViewType(int position) { 130 if (position >= mMediaItemList.size()) { 131 Log.d(TAG, "Incorrect position for item type: " + position); 132 return MediaItem.MediaItemType.TYPE_GROUP_DIVIDER; 133 } 134 return mMediaItemList.get(position).getMediaItemType(); 135 } 136 137 @Override getItemCount()138 public int getItemCount() { 139 return mMediaItemList.size(); 140 } 141 142 public abstract class MediaDeviceViewHolderBase extends RecyclerView.ViewHolder { 143 144 Context mContext; 145 MediaDeviceViewHolderBase(View view, Context context)146 MediaDeviceViewHolderBase(View view, Context context) { 147 super(view); 148 mContext = context; 149 } 150 renderItem(MediaItem mediaItem, int position)151 void renderItem(MediaItem mediaItem, int position) { 152 MediaDevice device = mediaItem.getMediaDevice().get(); 153 boolean isMutingExpectedDeviceExist = mController.hasMutingExpectedDevice(); 154 final boolean currentlyConnected = isCurrentlyConnected(device); 155 boolean isSelected = isDeviceIncluded(mController.getSelectedMediaDevice(), device); 156 boolean isDeselectable = 157 isDeviceIncluded(mController.getDeselectableMediaDevice(), device); 158 boolean isSelectable = isDeviceIncluded(mController.getSelectableMediaDevice(), device); 159 boolean isTransferable = 160 isDeviceIncluded(mController.getTransferableMediaDevices(), device); 161 boolean hasRouteListingPreferenceItem = device.hasRouteListingPreferenceItem(); 162 163 if (DEBUG) { 164 Log.d( 165 TAG, 166 "[" 167 + position 168 + "] " 169 + device.getName() 170 + " [" 171 + (isDeselectable ? "deselectable" : "") 172 + "] [" 173 + (isSelected ? "selected" : "") 174 + "] [" 175 + (isSelectable ? "selectable" : "") 176 + "] [" 177 + (isTransferable ? "transferable" : "") 178 + "] [" 179 + (hasRouteListingPreferenceItem ? "hasListingPreference" : "") 180 + "]"); 181 } 182 183 boolean isDeviceGroup = false; 184 boolean hideGroupItem = false; 185 GroupStatus groupStatus = null; 186 OngoingSessionStatus ongoingSessionStatus = null; 187 ConnectionState connectionState = ConnectionState.DISCONNECTED; 188 boolean restrictVolumeAdjustment = mController.hasAdjustVolumeUserRestriction(); 189 String subtitle = null; 190 Drawable deviceStatusIcon = null; 191 boolean deviceDisabled = false; 192 View.OnClickListener clickListener = null; 193 194 if (mCurrentActivePosition == position) { 195 mCurrentActivePosition = -1; 196 } 197 198 if (mController.isAnyDeviceTransferring()) { 199 if (device.getState() == MediaDeviceState.STATE_CONNECTING) { 200 connectionState = ConnectionState.CONNECTING; 201 } 202 } else { 203 // Set different layout for each device 204 if (device.isMutingExpectedDevice() 205 && !mController.isCurrentConnectedDeviceRemote()) { 206 connectionState = ConnectionState.CONNECTED; 207 restrictVolumeAdjustment = true; 208 clickListener = v -> onItemClick(v, device); 209 } else if (currentlyConnected && isMutingExpectedDeviceExist 210 && !mController.isCurrentConnectedDeviceRemote()) { 211 // mark as disconnected and set special click listener 212 clickListener = v -> cancelMuteAwaitConnection(); 213 } else if (device.getState() == MediaDeviceState.STATE_GROUPING) { 214 connectionState = ConnectionState.CONNECTING; 215 } else if (!enableOutputSwitcherRedesign() && mShouldGroupSelectedMediaItems 216 && hasMultipleSelectedDevices() 217 && isSelected) { 218 if (mediaItem.isFirstDeviceInGroup()) { 219 isDeviceGroup = true; 220 } else { 221 hideGroupItem = true; 222 } 223 } else { // A connected or disconnected device. 224 subtitle = device.hasSubtext() ? device.getSubtextString() : null; 225 ongoingSessionStatus = getOngoingSessionStatus(device); 226 groupStatus = getGroupStatus(isSelected, isSelectable, isDeselectable); 227 228 if (device.getState() == MediaDeviceState.STATE_CONNECTING_FAILED) { 229 deviceStatusIcon = mContext.getDrawable( 230 R.drawable.media_output_status_failed); 231 subtitle = mContext.getString(R.string.media_output_dialog_connect_failed); 232 clickListener = v -> onItemClick(v, device); 233 } else if (currentlyConnected || isSelected) { 234 connectionState = ConnectionState.CONNECTED; 235 } else { // disconnected 236 if (isSelectable) { // groupable device 237 if (!Flags.disableTransferWhenAppsDoNotSupport() || isTransferable 238 || hasRouteListingPreferenceItem) { 239 clickListener = v -> onItemClick(v, device); 240 } 241 } else { 242 deviceStatusIcon = getDeviceStatusIcon(device); 243 clickListener = getClickListenerBasedOnSelectionBehavior(device); 244 } 245 deviceDisabled = clickListener == null; 246 } 247 } 248 } 249 250 if (connectionState == ConnectionState.CONNECTED || isDeviceGroup) { 251 mCurrentActivePosition = position; 252 } 253 254 if (isDeviceGroup) { 255 renderDeviceGroupItem(); 256 } else { 257 renderDeviceItem(hideGroupItem, device, connectionState, restrictVolumeAdjustment, 258 groupStatus, ongoingSessionStatus, clickListener, deviceDisabled, subtitle, 259 deviceStatusIcon); 260 } 261 } 262 renderDeviceItem(boolean hideGroupItem, MediaDevice device, ConnectionState connectionState, boolean restrictVolumeAdjustment, GroupStatus groupStatus, OngoingSessionStatus ongoingSessionStatus, View.OnClickListener clickListener, boolean deviceDisabled, String subtitle, Drawable deviceStatusIcon)263 protected abstract void renderDeviceItem(boolean hideGroupItem, MediaDevice device, 264 ConnectionState connectionState, boolean restrictVolumeAdjustment, 265 GroupStatus groupStatus, OngoingSessionStatus ongoingSessionStatus, 266 View.OnClickListener clickListener, boolean deviceDisabled, String subtitle, 267 Drawable deviceStatusIcon); 268 renderDeviceGroupItem()269 protected abstract void renderDeviceGroupItem(); 270 disableSeekBar()271 protected abstract void disableSeekBar(); 272 getOngoingSessionStatus(MediaDevice device)273 private OngoingSessionStatus getOngoingSessionStatus(MediaDevice device) { 274 return device.hasOngoingSession() ? new OngoingSessionStatus( 275 device.isHostForOngoingSession()) : null; 276 } 277 getGroupStatus(boolean isSelected, boolean isSelectable, boolean isDeselectable)278 private GroupStatus getGroupStatus(boolean isSelected, boolean isSelectable, 279 boolean isDeselectable) { 280 // A device should either be selectable or, when the device selected, the list should 281 // have other selectable or selected devices. 282 boolean selectedWithOtherGroupDevices = 283 isSelected && (hasMultipleSelectedDevices() || hasSelectableDevices()); 284 if (isSelectable || selectedWithOtherGroupDevices) { 285 return new GroupStatus(isSelected, isDeselectable); 286 } 287 return null; 288 } 289 hasMultipleSelectedDevices()290 private boolean hasMultipleSelectedDevices() { 291 return mController.getSelectedMediaDevice().size() > 1; 292 } 293 hasSelectableDevices()294 private boolean hasSelectableDevices() { 295 return !mController.getSelectableMediaDevice().isEmpty(); 296 } 297 298 @Nullable getClickListenerBasedOnSelectionBehavior( @onNull MediaDevice device)299 private View.OnClickListener getClickListenerBasedOnSelectionBehavior( 300 @NonNull MediaDevice device) { 301 return Api34Impl.getClickListenerBasedOnSelectionBehavior( 302 device, mController, v -> onItemClick(v, device)); 303 } 304 305 @Nullable getDeviceStatusIcon(MediaDevice device)306 private Drawable getDeviceStatusIcon(MediaDevice device) { 307 return Api34Impl.getDeviceStatusIconBasedOnSelectionBehavior(device, mContext); 308 } 309 onExpandGroupButtonClicked()310 protected void onExpandGroupButtonClicked() { 311 mShouldGroupSelectedMediaItems = false; 312 notifyDataSetChanged(); 313 } 314 onGroupActionTriggered(boolean isChecked, MediaDevice device)315 protected void onGroupActionTriggered(boolean isChecked, MediaDevice device) { 316 disableSeekBar(); 317 if (isChecked && isDeviceIncluded(mController.getSelectableMediaDevice(), device)) { 318 mController.addDeviceToPlayMedia(device); 319 } else if (!isChecked && isDeviceIncluded(mController.getDeselectableMediaDevice(), 320 device)) { 321 mController.removeDeviceFromPlayMedia(device); 322 } 323 } 324 onItemClick(View view, MediaDevice device)325 private void onItemClick(View view, MediaDevice device) { 326 if (mController.isCurrentOutputDeviceHasSessionOngoing()) { 327 showCustomEndSessionDialog(device); 328 } else { 329 transferOutput(device); 330 } 331 } 332 transferOutput(MediaDevice device)333 private void transferOutput(MediaDevice device) { 334 if (mController.isAnyDeviceTransferring()) { 335 return; 336 } 337 if (isCurrentlyConnected(device)) { 338 Log.d(TAG, "This device is already connected! : " + device.getName()); 339 return; 340 } 341 mController.setTemporaryAllowListExceptionIfNeeded(device); 342 mCurrentActivePosition = -1; 343 mController.connectDevice(device); 344 device.setState(MediaDeviceState.STATE_CONNECTING); 345 notifyDataSetChanged(); 346 } 347 348 @VisibleForTesting showCustomEndSessionDialog(MediaDevice device)349 void showCustomEndSessionDialog(MediaDevice device) { 350 MediaSessionReleaseDialog mediaSessionReleaseDialog = new MediaSessionReleaseDialog( 351 mContext, () -> transferOutput(device), 352 mController.getColorSchemeLegacy().getColorButtonBackground(), 353 mController.getColorSchemeLegacy().getColorItemContent()); 354 mediaSessionReleaseDialog.show(); 355 } 356 cancelMuteAwaitConnection()357 private void cancelMuteAwaitConnection() { 358 mController.cancelMuteAwaitConnection(); 359 notifyDataSetChanged(); 360 } 361 getDeviceItemContentDescription(@onNull MediaDevice device)362 protected String getDeviceItemContentDescription(@NonNull MediaDevice device) { 363 return mContext.getString( 364 device.getDeviceType() == MediaDevice.MediaDeviceType.TYPE_BLUETOOTH_DEVICE 365 ? R.string.accessibility_bluetooth_name 366 : R.string.accessibility_cast_name, device.getName()); 367 } 368 getGroupItemContentDescription(String sessionName)369 protected String getGroupItemContentDescription(String sessionName) { 370 return mContext.getString(R.string.accessibility_cast_name, sessionName); 371 } 372 } 373 374 @RequiresApi(34) 375 private static class Api34Impl { 376 @DoNotInline getClickListenerBasedOnSelectionBehavior( MediaDevice device, MediaSwitchingController controller, View.OnClickListener defaultTransferListener)377 static View.OnClickListener getClickListenerBasedOnSelectionBehavior( 378 MediaDevice device, 379 MediaSwitchingController controller, 380 View.OnClickListener defaultTransferListener) { 381 switch (device.getSelectionBehavior()) { 382 case SELECTION_BEHAVIOR_NONE: 383 return null; 384 case SELECTION_BEHAVIOR_TRANSFER: 385 return defaultTransferListener; 386 case SELECTION_BEHAVIOR_GO_TO_APP: 387 return v -> controller.tryToLaunchInAppRoutingIntent(device.getId(), v); 388 } 389 return defaultTransferListener; 390 } 391 392 @DoNotInline 393 @Nullable getDeviceStatusIconBasedOnSelectionBehavior(MediaDevice device, Context context)394 static Drawable getDeviceStatusIconBasedOnSelectionBehavior(MediaDevice device, 395 Context context) { 396 switch (device.getSelectionBehavior()) { 397 case SELECTION_BEHAVIOR_NONE: 398 return context.getDrawable(R.drawable.media_output_status_failed); 399 case SELECTION_BEHAVIOR_TRANSFER: 400 return null; 401 case SELECTION_BEHAVIOR_GO_TO_APP: 402 return context.getDrawable(R.drawable.media_output_status_help); 403 } 404 return null; 405 } 406 } 407 } 408