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.wm.shell.bubbles; 18 19 import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_OVERFLOW; 20 import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES; 21 import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME; 22 23 import android.annotation.NonNull; 24 import android.content.Context; 25 import android.content.res.Configuration; 26 import android.content.res.Resources; 27 import android.content.res.TypedArray; 28 import android.graphics.Color; 29 import android.graphics.Rect; 30 import android.util.AttributeSet; 31 import android.util.Log; 32 import android.util.TypedValue; 33 import android.view.KeyEvent; 34 import android.view.LayoutInflater; 35 import android.view.View; 36 import android.view.ViewGroup; 37 import android.view.accessibility.AccessibilityNodeInfo; 38 import android.widget.ImageView; 39 import android.widget.LinearLayout; 40 import android.widget.TextView; 41 42 import androidx.annotation.Nullable; 43 import androidx.recyclerview.widget.GridLayoutManager; 44 import androidx.recyclerview.widget.RecyclerView; 45 46 import com.android.internal.util.ContrastColorUtil; 47 import com.android.wm.shell.R; 48 49 import java.util.ArrayList; 50 import java.util.List; 51 import java.util.function.Consumer; 52 53 /** 54 * Container view for showing aged out bubbles. 55 */ 56 public class BubbleOverflowContainerView extends LinearLayout { 57 private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleOverflowActivity" : TAG_BUBBLES; 58 59 private LinearLayout mEmptyState; 60 private TextView mEmptyStateTitle; 61 private TextView mEmptyStateSubtitle; 62 private ImageView mEmptyStateImage; 63 private int mHorizontalMargin; 64 private int mVerticalMargin; 65 private BubbleController mController; 66 private BubbleOverflowAdapter mAdapter; 67 private RecyclerView mRecyclerView; 68 private List<Bubble> mOverflowBubbles = new ArrayList<>(); 69 70 private View.OnKeyListener mKeyListener = (view, i, keyEvent) -> { 71 if (keyEvent.getAction() == KeyEvent.ACTION_UP 72 && keyEvent.getKeyCode() == KeyEvent.KEYCODE_BACK) { 73 mController.collapseStack(); 74 return true; 75 } 76 return false; 77 }; 78 79 private class OverflowGridLayoutManager extends GridLayoutManager { OverflowGridLayoutManager(Context context, int columns)80 OverflowGridLayoutManager(Context context, int columns) { 81 super(context, columns); 82 } 83 84 @Override getColumnCountForAccessibility(RecyclerView.Recycler recycler, RecyclerView.State state)85 public int getColumnCountForAccessibility(RecyclerView.Recycler recycler, 86 RecyclerView.State state) { 87 int bubbleCount = state.getItemCount(); 88 int columnCount = super.getColumnCountForAccessibility(recycler, state); 89 if (bubbleCount < columnCount) { 90 // If there are 4 columns and bubbles <= 3, 91 // TalkBack says "AppName 1 of 4 in list 4 items" 92 // This is a workaround until TalkBack bug is fixed for GridLayoutManager 93 return bubbleCount; 94 } 95 return columnCount; 96 } 97 } 98 99 private class OverflowItemDecoration extends RecyclerView.ItemDecoration { 100 @Override getItemOffsets(@onNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state)101 public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, 102 @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { 103 outRect.left = mHorizontalMargin; 104 outRect.top = mVerticalMargin; 105 outRect.right = mHorizontalMargin; 106 outRect.bottom = mVerticalMargin; 107 } 108 } 109 BubbleOverflowContainerView(Context context)110 public BubbleOverflowContainerView(Context context) { 111 this(context, null); 112 } 113 BubbleOverflowContainerView(Context context, @Nullable AttributeSet attrs)114 public BubbleOverflowContainerView(Context context, @Nullable AttributeSet attrs) { 115 this(context, attrs, 0); 116 } 117 BubbleOverflowContainerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr)118 public BubbleOverflowContainerView(Context context, @Nullable AttributeSet attrs, 119 int defStyleAttr) { 120 this(context, attrs, defStyleAttr, 0); 121 } 122 BubbleOverflowContainerView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)123 public BubbleOverflowContainerView(Context context, AttributeSet attrs, int defStyleAttr, 124 int defStyleRes) { 125 super(context, attrs, defStyleAttr, defStyleRes); 126 setFocusableInTouchMode(true); 127 } 128 setBubbleController(BubbleController controller)129 public void setBubbleController(BubbleController controller) { 130 mController = controller; 131 } 132 show()133 public void show() { 134 requestFocus(); 135 updateOverflow(); 136 } 137 138 @Override onFinishInflate()139 protected void onFinishInflate() { 140 super.onFinishInflate(); 141 142 mRecyclerView = findViewById(R.id.bubble_overflow_recycler); 143 mEmptyState = findViewById(R.id.bubble_overflow_empty_state); 144 mEmptyStateTitle = findViewById(R.id.bubble_overflow_empty_title); 145 mEmptyStateSubtitle = findViewById(R.id.bubble_overflow_empty_subtitle); 146 mEmptyStateImage = findViewById(R.id.bubble_overflow_empty_state_image); 147 } 148 149 @Override onAttachedToWindow()150 protected void onAttachedToWindow() { 151 super.onAttachedToWindow(); 152 if (mController != null) { 153 // For the overflow to get key events (e.g. back press) we need to adjust the flags 154 mController.updateWindowFlagsForBackpress(true); 155 } 156 setOnKeyListener(mKeyListener); 157 } 158 159 @Override onDetachedFromWindow()160 protected void onDetachedFromWindow() { 161 super.onDetachedFromWindow(); 162 if (mController != null) { 163 mController.updateWindowFlagsForBackpress(false); 164 } 165 setOnKeyListener(null); 166 } 167 updateOverflow()168 void updateOverflow() { 169 Resources res = getResources(); 170 int columns = (int) Math.round(getWidth() 171 / (res.getDimension(R.dimen.bubble_name_width))); 172 columns = columns > 0 ? columns : res.getInteger(R.integer.bubbles_overflow_columns); 173 174 mRecyclerView.setLayoutManager( 175 new OverflowGridLayoutManager(getContext(), columns)); 176 if (mRecyclerView.getItemDecorationCount() == 0) { 177 mRecyclerView.addItemDecoration(new OverflowItemDecoration()); 178 } 179 mAdapter = new BubbleOverflowAdapter(getContext(), mOverflowBubbles, 180 mController::promoteBubbleFromOverflow, 181 mController.getPositioner()); 182 mRecyclerView.setAdapter(mAdapter); 183 184 mOverflowBubbles.clear(); 185 mOverflowBubbles.addAll(mController.getOverflowBubbles()); 186 mAdapter.notifyDataSetChanged(); 187 188 mController.setOverflowListener(mDataListener); 189 updateEmptyStateVisibility(); 190 updateTheme(); 191 } 192 updateEmptyStateVisibility()193 void updateEmptyStateVisibility() { 194 mEmptyState.setVisibility(mOverflowBubbles.isEmpty() ? View.VISIBLE : View.GONE); 195 mRecyclerView.setVisibility(mOverflowBubbles.isEmpty() ? View.GONE : View.VISIBLE); 196 } 197 198 /** 199 * Handle theme changes. 200 */ updateTheme()201 void updateTheme() { 202 Resources res = getResources(); 203 final int mode = res.getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; 204 final boolean isNightMode = (mode == Configuration.UI_MODE_NIGHT_YES); 205 206 mHorizontalMargin = res.getDimensionPixelSize( 207 R.dimen.bubble_overflow_item_padding_horizontal); 208 mVerticalMargin = res.getDimensionPixelSize(R.dimen.bubble_overflow_item_padding_vertical); 209 if (mRecyclerView != null) { 210 mRecyclerView.invalidateItemDecorations(); 211 } 212 213 mEmptyStateImage.setImageDrawable(isNightMode 214 ? res.getDrawable(R.drawable.bubble_ic_empty_overflow_dark) 215 : res.getDrawable(R.drawable.bubble_ic_empty_overflow_light)); 216 217 findViewById(R.id.bubble_overflow_container) 218 .setBackgroundColor(isNightMode 219 ? res.getColor(R.color.bubbles_dark) 220 : res.getColor(R.color.bubbles_light)); 221 222 final TypedArray typedArray = getContext().obtainStyledAttributes(new int[] { 223 com.android.internal.R.attr.materialColorSurfaceBright, 224 com.android.internal.R.attr.materialColorOnSurface}); 225 int bgColor = typedArray.getColor(0, isNightMode ? Color.BLACK : Color.WHITE); 226 int textColor = typedArray.getColor(1, isNightMode ? Color.WHITE : Color.BLACK); 227 textColor = ContrastColorUtil.ensureTextContrast(textColor, bgColor, isNightMode); 228 typedArray.recycle(); 229 setBackgroundColor(bgColor); 230 mEmptyStateTitle.setTextColor(textColor); 231 mEmptyStateSubtitle.setTextColor(textColor); 232 } 233 updateFontSize()234 public void updateFontSize() { 235 final float fontSize = mContext.getResources() 236 .getDimensionPixelSize(com.android.internal.R.dimen.text_size_body_2_material); 237 mEmptyStateTitle.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize); 238 mEmptyStateSubtitle.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize); 239 } 240 241 private final BubbleData.Listener mDataListener = new BubbleData.Listener() { 242 243 @Override 244 public void applyUpdate(BubbleData.Update update) { 245 246 Bubble toRemove = update.removedOverflowBubble; 247 if (toRemove != null) { 248 if (DEBUG_OVERFLOW) { 249 Log.d(TAG, "remove: " + toRemove); 250 } 251 toRemove.cleanupViews(); 252 final int indexToRemove = mOverflowBubbles.indexOf(toRemove); 253 mOverflowBubbles.remove(toRemove); 254 mAdapter.notifyItemRemoved(indexToRemove); 255 } 256 257 Bubble toAdd = update.addedOverflowBubble; 258 if (toAdd != null) { 259 final int indexToAdd = mOverflowBubbles.indexOf(toAdd); 260 if (DEBUG_OVERFLOW) { 261 Log.d(TAG, "add: " + toAdd + " prevIndex: " + indexToAdd); 262 } 263 if (indexToAdd > 0) { 264 mOverflowBubbles.remove(toAdd); 265 mOverflowBubbles.add(0, toAdd); 266 mAdapter.notifyItemMoved(indexToAdd, 0); 267 } else { 268 mOverflowBubbles.add(0, toAdd); 269 mAdapter.notifyItemInserted(0); 270 } 271 } 272 273 updateEmptyStateVisibility(); 274 275 if (DEBUG_OVERFLOW) { 276 Log.d(TAG, BubbleDebugConfig.formatBubblesString( 277 mController.getOverflowBubbles(), null)); 278 } 279 } 280 }; 281 } 282 283 class BubbleOverflowAdapter extends RecyclerView.Adapter<BubbleOverflowAdapter.ViewHolder> { 284 private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleOverflowAdapter" : TAG_BUBBLES; 285 286 private Context mContext; 287 private Consumer<Bubble> mPromoteBubbleFromOverflow; 288 private BubblePositioner mPositioner; 289 private List<Bubble> mBubbles; 290 BubbleOverflowAdapter(Context context, List<Bubble> list, Consumer<Bubble> promoteBubble, BubblePositioner positioner)291 BubbleOverflowAdapter(Context context, 292 List<Bubble> list, 293 Consumer<Bubble> promoteBubble, 294 BubblePositioner positioner) { 295 mContext = context; 296 mBubbles = list; 297 mPromoteBubbleFromOverflow = promoteBubble; 298 mPositioner = positioner; 299 } 300 301 @Override onCreateViewHolder(ViewGroup parent, int viewType)302 public BubbleOverflowAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { 303 304 // Set layout for overflow bubble view. 305 LinearLayout overflowView = (LinearLayout) LayoutInflater.from(parent.getContext()) 306 .inflate(R.layout.bubble_overflow_view, parent, false); 307 LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( 308 LinearLayout.LayoutParams.WRAP_CONTENT, 309 LinearLayout.LayoutParams.WRAP_CONTENT); 310 overflowView.setLayoutParams(params); 311 312 // Ensure name has enough contrast. 313 final TypedArray ta = mContext.obtainStyledAttributes( 314 new int[]{android.R.attr.colorBackgroundFloating, android.R.attr.textColorPrimary}); 315 final int bgColor = ta.getColor(0, Color.WHITE); 316 int textColor = ta.getColor(1, Color.BLACK); 317 textColor = ContrastColorUtil.ensureTextContrast(textColor, bgColor, true); 318 ta.recycle(); 319 320 TextView viewName = overflowView.findViewById(R.id.bubble_view_name); 321 viewName.setTextColor(textColor); 322 323 return new ViewHolder(overflowView, mPositioner); 324 } 325 326 @Override onBindViewHolder(ViewHolder vh, int index)327 public void onBindViewHolder(ViewHolder vh, int index) { 328 Bubble b = mBubbles.get(index); 329 330 vh.iconView.setRenderedBubble(b); 331 vh.iconView.removeDotSuppressionFlag(BadgedImageView.SuppressionFlag.FLYOUT_VISIBLE); 332 vh.iconView.setOnClickListener(view -> { 333 mBubbles.remove(b); 334 notifyDataSetChanged(); 335 mPromoteBubbleFromOverflow.accept(b); 336 }); 337 338 String titleStr = b.getTitle(); 339 if (titleStr == null) { 340 titleStr = mContext.getResources().getString(R.string.notification_bubble_title); 341 } 342 vh.iconView.setContentDescription(mContext.getResources().getString( 343 R.string.bubble_content_description_single, titleStr, b.getAppName())); 344 345 vh.iconView.setAccessibilityDelegate( 346 new View.AccessibilityDelegate() { 347 @Override 348 public void onInitializeAccessibilityNodeInfo(View host, 349 AccessibilityNodeInfo info) { 350 super.onInitializeAccessibilityNodeInfo(host, info); 351 // Talkback prompts "Double tap to add back to stack" 352 // instead of the default "Double tap to activate" 353 info.addAction( 354 new AccessibilityNodeInfo.AccessibilityAction( 355 AccessibilityNodeInfo.ACTION_CLICK, 356 mContext.getResources().getString( 357 R.string.bubble_accessibility_action_add_back))); 358 } 359 }); 360 361 CharSequence label = b.getShortcutInfo() != null 362 ? b.getShortcutInfo().getLabel() 363 : b.getAppName(); 364 vh.textView.setText(label); 365 } 366 367 @Override getItemCount()368 public int getItemCount() { 369 return mBubbles.size(); 370 } 371 372 public static class ViewHolder extends RecyclerView.ViewHolder { 373 public BadgedImageView iconView; 374 public TextView textView; 375 ViewHolder(LinearLayout v, BubblePositioner positioner)376 ViewHolder(LinearLayout v, BubblePositioner positioner) { 377 super(v); 378 iconView = v.findViewById(R.id.bubble_view); 379 iconView.initialize(positioner); 380 textView = v.findViewById(R.id.bubble_view_name); 381 } 382 } 383 }