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