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