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