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 }