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 }