1 /*
2  * Copyright (C) 2021 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.systemui.car.statusicon;
18 
19 import static android.content.Intent.ACTION_USER_FOREGROUND;
20 import static android.view.WindowManager.LayoutParams.TYPE_SYSTEM_DIALOG;
21 import static android.widget.ListPopupWindow.WRAP_CONTENT;
22 
23 import android.annotation.ColorInt;
24 import android.annotation.DimenRes;
25 import android.annotation.LayoutRes;
26 import android.app.PendingIntent;
27 import android.car.drivingstate.CarUxRestrictions;
28 import android.content.BroadcastReceiver;
29 import android.content.Context;
30 import android.content.Intent;
31 import android.content.IntentFilter;
32 import android.os.UserHandle;
33 import android.view.Gravity;
34 import android.view.LayoutInflater;
35 import android.view.View;
36 import android.view.ViewGroup;
37 import android.view.ViewTreeObserver;
38 import android.view.WindowManager;
39 import android.widget.ImageView;
40 import android.widget.PopupWindow;
41 import android.widget.Toast;
42 
43 import androidx.annotation.NonNull;
44 import androidx.annotation.VisibleForTesting;
45 
46 import com.android.car.qc.QCItem;
47 import com.android.car.qc.view.QCView;
48 import com.android.car.ui.FocusParkingView;
49 import com.android.car.ui.utils.CarUxRestrictionsUtil;
50 import com.android.car.ui.utils.ViewUtils;
51 import com.android.systemui.R;
52 import com.android.systemui.broadcast.BroadcastDispatcher;
53 import com.android.systemui.car.CarServiceProvider;
54 import com.android.systemui.car.qc.SystemUIQCView;
55 import com.android.systemui.statusbar.policy.ConfigurationController;
56 
57 import java.util.ArrayList;
58 
59 /**
60  * A controller for a panel view associated with a status icon.
61  */
62 public class StatusIconPanelController {
63     private static final int DEFAULT_POPUP_WINDOW_ANCHOR_GRAVITY = Gravity.TOP | Gravity.START;
64     private static final IntentFilter INTENT_FILTER_USER_CHANGED = new IntentFilter(
65             ACTION_USER_FOREGROUND);
66 
67     private final Context mContext;
68     private final String mIdentifier;
69     private final String mIconTag;
70     private final @ColorInt int mIconHighlightedColor;
71     private final @ColorInt int mIconNotHighlightedColor;
72     private final int mYOffsetPixel;
73     private final boolean mIsDisabledWhileDriving;
74     private final ArrayList<SystemUIQCView> mQCViews = new ArrayList<>();
75 
76     private PopupWindow mPanel;
77     private ViewGroup mPanelContent;
78     private OnQcViewsFoundListener mOnQcViewsFoundListener;
79     private View mAnchorView;
80     private ImageView mStatusIconView;
81     private CarUxRestrictionsUtil mCarUxRestrictionsUtil;
82     private float mDimValue = -1.0f;
83 
84     private final BroadcastReceiver mUserChangeReceiver = new BroadcastReceiver() {
85         @Override
86         public void onReceive(Context context, Intent intent) {
87             reset();
88         }
89     };
90 
91     private final ConfigurationController.ConfigurationListener mConfigurationListener =
92             new ConfigurationController.ConfigurationListener() {
93                 @Override
94                 public void onLayoutDirectionChanged(boolean isLayoutRtl) {
95                     reset();
96                 }
97             };
98 
99     private final CarUxRestrictionsUtil.OnUxRestrictionsChangedListener
100             mUxRestrictionsChangedListener =
101             new CarUxRestrictionsUtil.OnUxRestrictionsChangedListener() {
102                 @Override
103                 public void onRestrictionsChanged(@NonNull CarUxRestrictions carUxRestrictions) {
104                     if (mIsDisabledWhileDriving
105                             && carUxRestrictions.isRequiresDistractionOptimization()
106                             && isPanelShowing()) {
107                         mPanel.dismiss();
108                     }
109                 }
110             };
111 
112     private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
113         @Override
114         public void onReceive(Context context, Intent intent) {
115             String action = intent.getAction();
116             boolean isIntentFromSelf =
117                     intent.getIdentifier() != null && intent.getIdentifier().equals(mIdentifier);
118 
119             if (Intent.ACTION_CLOSE_SYSTEM_DIALOGS.equals(action) && !isIntentFromSelf
120                     && isPanelShowing()) {
121                 mPanel.dismiss();
122             }
123         }
124     };
125 
126     private final ViewTreeObserver.OnGlobalFocusChangeListener mFocusChangeListener =
127             (oldFocus, newFocus) -> {
128                 if (isPanelShowing() && oldFocus != null && newFocus instanceof FocusParkingView) {
129                     // When nudging out of the panel, RotaryService will focus on the
130                     // FocusParkingView to clear the focus highlight. When this occurs, dismiss the
131                     // panel.
132                     mPanel.dismiss();
133                 }
134             };
135 
136     private final QCView.QCActionListener mQCActionListener = (item, action) -> {
137         if (!isPanelShowing()) {
138             return;
139         }
140         if (action instanceof PendingIntent) {
141             if (((PendingIntent) action).isActivity()) {
142                 mPanel.dismiss();
143             }
144         } else if (action instanceof QCItem.ActionHandler) {
145             if (((QCItem.ActionHandler) action).isActivity()) {
146                 mPanel.dismiss();
147             }
148         }
149     };
150 
StatusIconPanelController( Context context, CarServiceProvider carServiceProvider, BroadcastDispatcher broadcastDispatcher, ConfigurationController configurationController)151     public StatusIconPanelController(
152             Context context,
153             CarServiceProvider carServiceProvider,
154             BroadcastDispatcher broadcastDispatcher,
155             ConfigurationController configurationController) {
156         this(context, carServiceProvider, broadcastDispatcher, configurationController,
157                 /* isDisabledWhileDriving= */ false);
158     }
159 
StatusIconPanelController( Context context, CarServiceProvider carServiceProvider, BroadcastDispatcher broadcastDispatcher, ConfigurationController configurationController, boolean isDisabledWhileDriving)160     public StatusIconPanelController(
161             Context context,
162             CarServiceProvider carServiceProvider,
163             BroadcastDispatcher broadcastDispatcher,
164             ConfigurationController configurationController,
165             boolean isDisabledWhileDriving) {
166         mContext = context;
167         mIdentifier = Integer.toString(System.identityHashCode(this));
168 
169         mIconTag = mContext.getResources().getString(R.string.qc_icon_tag);
170         mIconHighlightedColor = mContext.getColor(R.color.status_icon_highlighted_color);
171         mIconNotHighlightedColor = mContext.getColor(R.color.status_icon_not_highlighted_color);
172 
173         int panelMarginTop = mContext.getResources().getDimensionPixelSize(
174                 R.dimen.car_status_icon_panel_margin_top);
175         int topSystemBarHeight = mContext.getResources().getDimensionPixelSize(
176                 R.dimen.car_top_system_bar_height);
177         // Cancel out the superfluous inset automatically applied to the panel.
178         mYOffsetPixel = panelMarginTop - topSystemBarHeight;
179 
180         broadcastDispatcher.registerReceiver(mBroadcastReceiver,
181                 new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS), /* executor= */ null,
182                 UserHandle.ALL);
183         configurationController.addCallback(mConfigurationListener);
184 
185         context.registerReceiverForAllUsers(mUserChangeReceiver, INTENT_FILTER_USER_CHANGED,
186                 /* broadcastPermission= */ null, /* scheduler= */ null);
187 
188         mIsDisabledWhileDriving = isDisabledWhileDriving;
189         if (mIsDisabledWhileDriving) {
190             mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(mContext);
191             mCarUxRestrictionsUtil.register(mUxRestrictionsChangedListener);
192         }
193     }
194 
195     /**
196      * @return default Y offset in pixels that cancels out the superfluous inset automatically
197      *         applied to the panel
198      */
getDefaultYOffset()199     public int getDefaultYOffset() {
200         return mYOffsetPixel;
201     }
202 
203     /**
204      * @return list of {@link SystemUIQCView} in this controller
205      */
getQCViews()206     public ArrayList<SystemUIQCView> getQCViews() {
207         return mQCViews;
208     }
209 
setOnQcViewsFoundListener(OnQcViewsFoundListener onQcViewsFoundListener)210     public void setOnQcViewsFoundListener(OnQcViewsFoundListener onQcViewsFoundListener) {
211         mOnQcViewsFoundListener = onQcViewsFoundListener;
212     }
213 
214     /**
215      * A listener that can be used to attach controllers quick control panels using
216      * {@link SystemUIQCView#getLocalQCProvider()}
217      */
218     public interface OnQcViewsFoundListener {
219         /**
220          * This method is call up when {@link SystemUIQCView}s are found
221          */
qcViewsFound(ArrayList<SystemUIQCView> qcViews)222         void qcViewsFound(ArrayList<SystemUIQCView> qcViews);
223     }
224 
225     /**
226      * Attaches a panel to a root view that toggles the panel visibility when clicked.
227      *
228      * Variant of {@link #attachPanel(View, int, int, int, int, int)} with
229      * xOffset={@code 0}, yOffset={@link #mYOffsetPixel} &
230      * gravity={@link #DEFAULT_POPUP_WINDOW_ANCHOR_GRAVITY}.
231      */
attachPanel(View view, @LayoutRes int layoutRes, @DimenRes int widthRes)232     public void attachPanel(View view, @LayoutRes int layoutRes, @DimenRes int widthRes) {
233         attachPanel(view, layoutRes, widthRes, DEFAULT_POPUP_WINDOW_ANCHOR_GRAVITY);
234     }
235 
236     /**
237      * Attaches a panel to a root view that toggles the panel visibility when clicked.
238      *
239      * Variant of {@link #attachPanel(View, int, int, int, int, int)} with
240      * xOffset={@code 0} & yOffset={@link #mYOffsetPixel}.
241      */
attachPanel(View view, @LayoutRes int layoutRes, @DimenRes int widthRes, int gravity)242     public void attachPanel(View view, @LayoutRes int layoutRes, @DimenRes int widthRes,
243             int gravity) {
244         attachPanel(view, layoutRes, widthRes, /* xOffset= */ 0, mYOffsetPixel,
245                 gravity);
246     }
247 
248     /**
249      * Attaches a panel to a root view that toggles the panel visibility when clicked.
250      *
251      * Variant of {@link #attachPanel(View, int, int, int, int, int)} with
252      * gravity={@link #DEFAULT_POPUP_WINDOW_ANCHOR_GRAVITY}.
253      */
attachPanel(View view, @LayoutRes int layoutRes, @DimenRes int widthRes, int xOffset, int yOffset)254     public void attachPanel(View view, @LayoutRes int layoutRes, @DimenRes int widthRes,
255             int xOffset, int yOffset) {
256         attachPanel(view, layoutRes, widthRes, xOffset, yOffset,
257                 DEFAULT_POPUP_WINDOW_ANCHOR_GRAVITY);
258     }
259 
260     /**
261      * Attaches a panel to a root view that toggles the panel visibility when clicked.
262      */
attachPanel(View view, @LayoutRes int layoutRes, @DimenRes int widthRes, int xOffset, int yOffset, int gravity)263     public void attachPanel(View view, @LayoutRes int layoutRes, @DimenRes int widthRes,
264             int xOffset, int yOffset, int gravity) {
265         if (mAnchorView == null) {
266             mAnchorView = view;
267         }
268 
269         mAnchorView.setOnClickListener(v -> {
270             if (mIsDisabledWhileDriving && mCarUxRestrictionsUtil.getCurrentRestrictions()
271                     .isRequiresDistractionOptimization()) {
272                 dismissAllSystemDialogs();
273                 Toast.makeText(mContext, R.string.car_ui_restricted_while_driving,
274                         Toast.LENGTH_LONG).show();
275                 return;
276             }
277 
278             if (mPanel == null) {
279                 mPanel = createPanel(layoutRes, widthRes);
280             }
281 
282             if (mPanel.isShowing()) {
283                 mPanel.dismiss();
284                 return;
285             }
286 
287             // Dismiss all currently open system dialogs before opening this panel.
288             dismissAllSystemDialogs();
289 
290             mQCViews.forEach(qcView -> qcView.listen(true));
291 
292             // Clear the focus highlight in this window since a dialog window is about to show.
293             // TODO(b/201700195): remove this workaround once the window focus issue is fixed.
294             if (view.isFocused()) {
295                 ViewUtils.hideFocus(view.getRootView());
296             }
297             registerFocusListener(true);
298 
299             // TODO(b/202563671): remove yOffsetPixel when the PopupWindow API is updated.
300             mPanel.showAsDropDown(mAnchorView, xOffset, yOffset, gravity);
301             mAnchorView.setSelected(true);
302             highlightStatusIcon(true);
303             setAnimatedStatusIconHighlightedStatus(true);
304 
305             dimBehind(mPanel);
306         });
307     }
308 
309     @VisibleForTesting
getPanel()310     protected PopupWindow getPanel() {
311         return mPanel;
312     }
313 
314     @VisibleForTesting
getBroadcastReceiver()315     protected BroadcastReceiver getBroadcastReceiver() {
316         return mBroadcastReceiver;
317     }
318 
319     @VisibleForTesting
getIdentifier()320     protected String getIdentifier() {
321         return mIdentifier;
322     }
323 
324     @VisibleForTesting
325     @ColorInt
getIconHighlightedColor()326     protected int getIconHighlightedColor() {
327         return mIconHighlightedColor;
328     }
329 
330     @VisibleForTesting
331     @ColorInt
getIconNotHighlightedColor()332     protected int getIconNotHighlightedColor() {
333         return mIconNotHighlightedColor;
334     }
335 
createPanel(@ayoutRes int layoutRes, @DimenRes int widthRes)336     private PopupWindow createPanel(@LayoutRes int layoutRes, @DimenRes int widthRes) {
337         int panelWidth = mContext.getResources().getDimensionPixelSize(widthRes);
338 
339         mPanelContent = (ViewGroup) LayoutInflater.from(mContext).inflate(layoutRes, /* root= */
340                 null);
341         mPanelContent.setLayoutDirection(View.LAYOUT_DIRECTION_LOCALE);
342         findQcViews(mPanelContent);
343         if (mOnQcViewsFoundListener != null) {
344             mOnQcViewsFoundListener.qcViewsFound(mQCViews);
345         }
346         PopupWindow panel = new PopupWindow(mPanelContent, panelWidth, WRAP_CONTENT);
347         panel.setBackgroundDrawable(
348                 mContext.getResources().getDrawable(R.drawable.status_icon_panel_bg,
349                         mContext.getTheme()));
350         panel.setWindowLayoutType(TYPE_SYSTEM_DIALOG);
351         panel.setFocusable(true);
352         panel.setOutsideTouchable(false);
353         panel.setOnDismissListener(() -> {
354             setAnimatedStatusIconHighlightedStatus(false);
355             mAnchorView.setSelected(false);
356             highlightStatusIcon(false);
357             registerFocusListener(false);
358             mQCViews.forEach(qcView -> qcView.listen(false));
359         });
360         addFocusParkingView();
361 
362         return panel;
363     }
364 
dimBehind(PopupWindow popupWindow)365     private void dimBehind(PopupWindow popupWindow) {
366         View container = popupWindow.getContentView().getRootView();
367         WindowManager wm = mContext.getSystemService(WindowManager.class);
368 
369         if (wm == null) return;
370 
371         if (mDimValue < 0) {
372             mDimValue = mContext.getResources().getFloat(R.dimen.car_status_icon_panel_dim);
373         }
374 
375         WindowManager.LayoutParams lp = (WindowManager.LayoutParams) container.getLayoutParams();
376         lp.flags |= WindowManager.LayoutParams.FLAG_DIM_BEHIND;
377         lp.dimAmount = mDimValue;
378         wm.updateViewLayout(container, lp);
379     }
380 
dismissAllSystemDialogs()381     private void dismissAllSystemDialogs() {
382         Intent intent = new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS);
383         intent.setIdentifier(mIdentifier);
384         mContext.sendBroadcastAsUser(intent, UserHandle.CURRENT);
385     }
386 
387     /**
388      * Add a FocusParkingView to the panel content to prevent rotary controller rotation wrapping
389      * around in the panel - this only should be called once per panel.
390      */
addFocusParkingView()391     private void addFocusParkingView() {
392         if (mPanelContent != null) {
393             FocusParkingView fpv = new FocusParkingView(mContext);
394             mPanelContent.addView(fpv);
395         }
396     }
397 
registerFocusListener(boolean register)398     private void registerFocusListener(boolean register) {
399         if (mPanelContent == null) {
400             return;
401         }
402         if (register) {
403             mPanelContent.getViewTreeObserver().addOnGlobalFocusChangeListener(
404                     mFocusChangeListener);
405         } else {
406             mPanelContent.getViewTreeObserver().removeOnGlobalFocusChangeListener(
407                     mFocusChangeListener);
408         }
409     }
410 
reset()411     private void reset() {
412         if (mPanel == null) return;
413 
414         mPanel.dismiss();
415         mPanel = null;
416         mPanelContent = null;
417         mOnQcViewsFoundListener = null;
418         mQCViews.forEach(v -> v.destroy());
419         mQCViews.clear();
420     }
421 
findQcViews(ViewGroup rootView)422     private void findQcViews(ViewGroup rootView) {
423         for (int i = 0; i < rootView.getChildCount(); i++) {
424             View v = rootView.getChildAt(i);
425             if (v instanceof SystemUIQCView) {
426                 SystemUIQCView qcv = (SystemUIQCView) v;
427                 mQCViews.add(qcv);
428                 qcv.setActionListener(mQCActionListener);
429             } else if (v instanceof ViewGroup) {
430                 this.findQcViews((ViewGroup) v);
431             }
432         }
433     }
434 
setAnimatedStatusIconHighlightedStatus(boolean isHighlighted)435     private void setAnimatedStatusIconHighlightedStatus(boolean isHighlighted) {
436         if (mAnchorView instanceof AnimatedStatusIcon) {
437             ((AnimatedStatusIcon) mAnchorView).setIconHighlighted(isHighlighted);
438         }
439     }
440 
highlightStatusIcon(boolean isHighlighted)441     private void highlightStatusIcon(boolean isHighlighted) {
442         if (mStatusIconView == null) {
443             mStatusIconView = mAnchorView.findViewWithTag(mIconTag);
444         }
445 
446         if (mStatusIconView != null) {
447             mStatusIconView.setColorFilter(
448                     isHighlighted ? mIconHighlightedColor : mIconNotHighlightedColor);
449         }
450     }
451 
isPanelShowing()452     private boolean isPanelShowing() {
453         return mPanel != null && mPanel.isShowing();
454     }
455 }
456