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