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