1 /* 2 * Copyright (C) 2023 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.view.WindowManager.LayoutParams.TYPE_SYSTEM_DIALOG; 20 import static android.widget.ListPopupWindow.WRAP_CONTENT; 21 22 import android.annotation.DimenRes; 23 import android.annotation.LayoutRes; 24 import android.app.PendingIntent; 25 import android.car.app.CarActivityManager; 26 import android.car.drivingstate.CarUxRestrictions; 27 import android.content.BroadcastReceiver; 28 import android.content.Context; 29 import android.content.Intent; 30 import android.content.IntentFilter; 31 import android.graphics.Outline; 32 import android.graphics.drawable.Drawable; 33 import android.view.Gravity; 34 import android.view.LayoutInflater; 35 import android.view.View; 36 import android.view.ViewGroup; 37 import android.view.ViewOutlineProvider; 38 import android.view.ViewTreeObserver; 39 import android.view.WindowManager; 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.systemui.R; 51 import com.android.systemui.broadcast.BroadcastDispatcher; 52 import com.android.systemui.car.CarDeviceProvisionedController; 53 import com.android.systemui.car.CarServiceProvider; 54 import com.android.systemui.car.qc.SystemUIQCViewController; 55 import com.android.systemui.car.systembar.element.CarSystemBarElementController; 56 import com.android.systemui.car.systembar.element.CarSystemBarElementInitializer; 57 import com.android.systemui.car.users.CarSystemUIUserUtil; 58 import com.android.systemui.settings.UserTracker; 59 import com.android.systemui.statusbar.policy.ConfigurationController; 60 import com.android.systemui.util.ViewController; 61 62 import java.util.ArrayList; 63 import java.util.List; 64 65 import javax.inject.Inject; 66 67 /** 68 * A controller for a panel view associated with a status icon. 69 */ 70 public class StatusIconPanelViewController extends ViewController<View> { 71 private final Context mContext; 72 private final UserTracker mUserTracker; 73 private final CarServiceProvider mCarServiceProvider; 74 private final BroadcastDispatcher mBroadcastDispatcher; 75 private final ConfigurationController mConfigurationController; 76 private final CarDeviceProvisionedController mCarDeviceProvisionedController; 77 private final CarSystemBarElementInitializer mCarSystemBarElementInitializer; 78 private final String mIdentifier; 79 @LayoutRes 80 private final int mPanelLayoutRes; 81 @DimenRes 82 private final int mPanelWidthRes; 83 private final int mXOffsetPixel; 84 private final int mYOffsetPixel; 85 private final int mPanelGravity; 86 private final boolean mIsDisabledWhileDriving; 87 private final boolean mIsDisabledWhileUnprovisioned; 88 private final boolean mShowAsDropDown; 89 private final ArrayList<SystemUIQCViewController> mQCViewControllers = new ArrayList<>(); 90 91 private PopupWindow mPanel; 92 private ViewGroup mPanelContent; 93 private CarUxRestrictionsUtil mCarUxRestrictionsUtil; 94 private CarActivityManager mCarActivityManager; 95 private float mDimValue = -1.0f; 96 private View.OnClickListener mOnClickListener; 97 98 private final ConfigurationController.ConfigurationListener mConfigurationListener = 99 new ConfigurationController.ConfigurationListener() { 100 @Override 101 public void onLayoutDirectionChanged(boolean isLayoutRtl) { 102 recreatePanel(); 103 } 104 }; 105 106 private final View.OnLayoutChangeListener mPanelContentLayoutChangeListener = 107 new View.OnLayoutChangeListener() { 108 @Override 109 public void onLayoutChange(View v, int left, int top, int right, int bottom, 110 int oldLeft, int oldTop, int oldRight, int oldBottom) { 111 if (mPanelContent != null) { 112 mPanelContent.invalidateOutline(); 113 } 114 } 115 }; 116 117 private final CarUxRestrictionsUtil.OnUxRestrictionsChangedListener 118 mUxRestrictionsChangedListener = 119 new CarUxRestrictionsUtil.OnUxRestrictionsChangedListener() { 120 @Override 121 public void onRestrictionsChanged(@NonNull CarUxRestrictions carUxRestrictions) { 122 if (mIsDisabledWhileDriving 123 && carUxRestrictions.isRequiresDistractionOptimization() 124 && isPanelShowing()) { 125 mPanel.dismiss(); 126 } 127 } 128 }; 129 130 private final CarServiceProvider.CarServiceOnConnectedListener mCarServiceOnConnectedListener = 131 car -> { 132 mCarActivityManager = car.getCarManager(CarActivityManager.class); 133 }; 134 135 private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() { 136 @Override 137 public void onReceive(Context context, Intent intent) { 138 String action = intent.getAction(); 139 boolean isIntentFromSelf = 140 intent.getIdentifier() != null && intent.getIdentifier().equals(mIdentifier); 141 142 if (Intent.ACTION_CLOSE_SYSTEM_DIALOGS.equals(action) && !isIntentFromSelf 143 && isPanelShowing()) { 144 mPanel.dismiss(); 145 } 146 } 147 }; 148 149 private final UserTracker.Callback mUserTrackerCallback = new UserTracker.Callback() { 150 @Override 151 public void onUserChanged(int newUser, Context userContext) { 152 mBroadcastDispatcher.unregisterReceiver(mBroadcastReceiver); 153 mBroadcastDispatcher.registerReceiver(mBroadcastReceiver, 154 new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS), /* executor= */ null, 155 mUserTracker.getUserHandle()); 156 } 157 }; 158 159 private final ViewTreeObserver.OnGlobalFocusChangeListener mFocusChangeListener = 160 (oldFocus, newFocus) -> { 161 if (isPanelShowing() && oldFocus != null && newFocus instanceof FocusParkingView) { 162 // When nudging out of the panel, RotaryService will focus on the 163 // FocusParkingView to clear the focus highlight. When this occurs, dismiss the 164 // panel. 165 mPanel.dismiss(); 166 } 167 }; 168 169 private final QCView.QCActionListener mQCActionListener = (item, action) -> { 170 if (!isPanelShowing()) { 171 return; 172 } 173 if (action instanceof PendingIntent) { 174 if (((PendingIntent) action).isActivity()) { 175 mPanel.dismiss(); 176 } 177 } else if (action instanceof QCItem.ActionHandler) { 178 if (((QCItem.ActionHandler) action).isActivity()) { 179 mPanel.dismiss(); 180 } 181 } 182 }; 183 StatusIconPanelViewController(Context context, UserTracker userTracker, CarServiceProvider carServiceProvider, BroadcastDispatcher broadcastDispatcher, ConfigurationController configurationController, CarDeviceProvisionedController deviceProvisionedController, CarSystemBarElementInitializer elementInitializer, View anchorView, @LayoutRes int layoutRes, @DimenRes int widthRes, int xOffset, int yOffset, int gravity, boolean isDisabledWhileDriving, boolean isDisabledWhileUnprovisioned, boolean showAsDropDown)184 private StatusIconPanelViewController(Context context, 185 UserTracker userTracker, 186 CarServiceProvider carServiceProvider, 187 BroadcastDispatcher broadcastDispatcher, 188 ConfigurationController configurationController, 189 CarDeviceProvisionedController deviceProvisionedController, 190 CarSystemBarElementInitializer elementInitializer, 191 View anchorView, @LayoutRes int layoutRes, @DimenRes int widthRes, 192 int xOffset, int yOffset, int gravity, boolean isDisabledWhileDriving, 193 boolean isDisabledWhileUnprovisioned, boolean showAsDropDown) { 194 super(anchorView); 195 mContext = context; 196 mUserTracker = userTracker; 197 mCarServiceProvider = carServiceProvider; 198 mBroadcastDispatcher = broadcastDispatcher; 199 mConfigurationController = configurationController; 200 mCarDeviceProvisionedController = deviceProvisionedController; 201 mCarSystemBarElementInitializer = elementInitializer; 202 mIsDisabledWhileDriving = isDisabledWhileDriving; 203 mIsDisabledWhileUnprovisioned = isDisabledWhileUnprovisioned; 204 mPanelLayoutRes = layoutRes; 205 mPanelWidthRes = widthRes; 206 mXOffsetPixel = xOffset; 207 mYOffsetPixel = yOffset; 208 mPanelGravity = gravity; 209 mShowAsDropDown = showAsDropDown; 210 mIdentifier = Integer.toString(System.identityHashCode(this)); 211 } 212 213 @Override onInit()214 protected void onInit() { 215 mOnClickListener = v -> { 216 if (mIsDisabledWhileUnprovisioned && !isDeviceSetupForUser()) { 217 return; 218 } 219 if (mIsDisabledWhileDriving && mCarUxRestrictionsUtil.getCurrentRestrictions() 220 .isRequiresDistractionOptimization()) { 221 dismissAllSystemDialogs(); 222 Toast.makeText(mContext, R.string.car_ui_restricted_while_driving, 223 Toast.LENGTH_LONG).show(); 224 return; 225 } 226 227 if (mPanel == null && !createPanel()) { 228 return; 229 } 230 231 if (mPanel.isShowing()) { 232 mPanel.dismiss(); 233 return; 234 } 235 236 // Dismiss all currently open system dialogs before opening this panel. 237 dismissAllSystemDialogs(); 238 239 registerFocusListener(true); 240 241 if (CarSystemUIUserUtil.isMUMDSystemUI() 242 && mPanelLayoutRes == R.layout.qc_profile_switcher) { 243 // TODO(b/269490856): consider removal of UserPicker carve-outs 244 if (mCarActivityManager != null) { 245 mCarActivityManager.startUserPickerOnDisplay(mContext.getDisplayId()); 246 } 247 } else { 248 if (mShowAsDropDown) { 249 // TODO(b/202563671): remove yOffsetPixel when the PopupWindow API is updated. 250 mPanel.showAsDropDown(mView, mXOffsetPixel, mYOffsetPixel, mPanelGravity); 251 } else { 252 int verticalGravity = mPanelGravity & Gravity.VERTICAL_GRAVITY_MASK; 253 int animationStyle = verticalGravity == Gravity.BOTTOM 254 ? com.android.internal.R.style.Animation_DropDownUp 255 : com.android.internal.R.style.Animation_DropDownDown; 256 mPanel.setAnimationStyle(animationStyle); 257 mPanel.showAtLocation(mView, mPanelGravity, mXOffsetPixel, mYOffsetPixel); 258 } 259 mView.setSelected(true); 260 setAnimatedStatusIconHighlightedStatus(true); 261 dimBehind(mPanel); 262 } 263 }; 264 265 mView.setOnClickListener(mOnClickListener); 266 } 267 268 @Override onViewAttached()269 protected void onViewAttached() { 270 if (mPanel == null) { 271 createPanel(); 272 } 273 mBroadcastDispatcher.registerReceiver(mBroadcastReceiver, 274 new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS), /* executor= */ null, 275 mUserTracker.getUserHandle()); 276 mUserTracker.addCallback(mUserTrackerCallback, mContext.getMainExecutor()); 277 mConfigurationController.addCallback(mConfigurationListener); 278 279 if (mIsDisabledWhileDriving) { 280 mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(mContext); 281 mCarUxRestrictionsUtil.register(mUxRestrictionsChangedListener); 282 } 283 mCarServiceProvider.addListener(mCarServiceOnConnectedListener); 284 } 285 286 @Override onViewDetached()287 protected void onViewDetached() { 288 reset(); 289 if (mCarUxRestrictionsUtil != null) { 290 mCarUxRestrictionsUtil.unregister(mUxRestrictionsChangedListener); 291 } 292 mCarServiceProvider.removeListener(mCarServiceOnConnectedListener); 293 mConfigurationController.removeCallback(mConfigurationListener); 294 mUserTracker.removeCallback(mUserTrackerCallback); 295 mBroadcastDispatcher.unregisterReceiver(mBroadcastReceiver); 296 } 297 298 @VisibleForTesting getPanel()299 PopupWindow getPanel() { 300 return mPanel; 301 } 302 303 @VisibleForTesting getBroadcastReceiver()304 BroadcastReceiver getBroadcastReceiver() { 305 return mBroadcastReceiver; 306 } 307 308 @VisibleForTesting getIdentifier()309 String getIdentifier() { 310 return mIdentifier; 311 } 312 313 @VisibleForTesting getOnClickListener()314 View.OnClickListener getOnClickListener() { 315 return mOnClickListener; 316 } 317 318 @VisibleForTesting getConfigurationListener()319 ConfigurationController.ConfigurationListener getConfigurationListener() { 320 return mConfigurationListener; 321 } 322 323 @VisibleForTesting getUserTrackerCallback()324 UserTracker.Callback getUserTrackerCallback() { 325 return mUserTrackerCallback; 326 } 327 328 @VisibleForTesting getFocusChangeListener()329 ViewTreeObserver.OnGlobalFocusChangeListener getFocusChangeListener() { 330 return mFocusChangeListener; 331 } 332 333 @VisibleForTesting getQCActionListener()334 QCView.QCActionListener getQCActionListener() { 335 return mQCActionListener; 336 } 337 338 /** 339 * Create the PopupWindow panel and assign to {@link mPanel}. 340 * @return true if the panel was created, false otherwise 341 */ createPanel()342 private boolean createPanel() { 343 if (mPanelWidthRes == 0 || mPanelLayoutRes == 0) { 344 return false; 345 } 346 347 int panelWidth = mContext.getResources().getDimensionPixelSize(mPanelWidthRes); 348 Drawable panelBackgroundDrawable = mContext.getResources() 349 .getDrawable(R.drawable.status_icon_panel_bg, mContext.getTheme()); 350 mPanelContent = (ViewGroup) LayoutInflater.from(mContext).inflate(mPanelLayoutRes, 351 /* root= */ null); 352 // clip content to the panel background (to handle rounded corners) 353 mPanelContent.setOutlineProvider(new DrawableViewOutlineProvider(panelBackgroundDrawable)); 354 mPanelContent.setClipToOutline(true); 355 mPanelContent.addOnLayoutChangeListener(mPanelContentLayoutChangeListener); 356 357 // initialize special views 358 initQCElementViews(mPanelContent); 359 360 // initialize panel 361 mPanel = new PopupWindow(mPanelContent, panelWidth, WRAP_CONTENT); 362 mPanel.setBackgroundDrawable(panelBackgroundDrawable); 363 mPanel.setWindowLayoutType(TYPE_SYSTEM_DIALOG); 364 mPanel.setFocusable(true); 365 mPanel.setOutsideTouchable(false); 366 mPanel.setOnDismissListener(() -> { 367 setAnimatedStatusIconHighlightedStatus(false); 368 mView.setSelected(false); 369 registerFocusListener(false); 370 }); 371 372 return true; 373 } 374 dimBehind(PopupWindow popupWindow)375 private void dimBehind(PopupWindow popupWindow) { 376 View container = popupWindow.getContentView().getRootView(); 377 WindowManager wm = mContext.getSystemService(WindowManager.class); 378 379 if (wm == null) return; 380 381 if (mDimValue < 0) { 382 mDimValue = mContext.getResources().getFloat(R.dimen.car_status_icon_panel_dim); 383 } 384 385 WindowManager.LayoutParams lp = (WindowManager.LayoutParams) container.getLayoutParams(); 386 lp.flags |= WindowManager.LayoutParams.FLAG_DIM_BEHIND; 387 lp.dimAmount = mDimValue; 388 wm.updateViewLayout(container, lp); 389 } 390 dismissAllSystemDialogs()391 private void dismissAllSystemDialogs() { 392 Intent intent = new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS); 393 intent.setIdentifier(mIdentifier); 394 mContext.sendBroadcastAsUser(intent, mUserTracker.getUserHandle()); 395 } 396 registerFocusListener(boolean register)397 private void registerFocusListener(boolean register) { 398 if (mPanelContent == null) { 399 return; 400 } 401 if (register) { 402 mPanelContent.getViewTreeObserver().addOnGlobalFocusChangeListener( 403 mFocusChangeListener); 404 } else { 405 mPanelContent.getViewTreeObserver().removeOnGlobalFocusChangeListener( 406 mFocusChangeListener); 407 } 408 } 409 reset()410 private void reset() { 411 if (mPanel == null) return; 412 413 mPanel.dismiss(); 414 mPanel = null; 415 if (mPanelContent != null) { 416 mPanelContent.removeOnLayoutChangeListener(mPanelContentLayoutChangeListener); 417 } 418 mPanelContent = null; 419 mQCViewControllers.forEach(SystemUIQCViewController::destroyQCViews); 420 mQCViewControllers.clear(); 421 } 422 recreatePanel()423 private void recreatePanel() { 424 reset(); 425 createPanel(); 426 } 427 initQCElementViews(ViewGroup rootView)428 private void initQCElementViews(ViewGroup rootView) { 429 List<CarSystemBarElementController> controllers = 430 mCarSystemBarElementInitializer.initializeCarSystemBarElements(rootView); 431 for (CarSystemBarElementController controller : controllers) { 432 if (controller instanceof SystemUIQCViewController) { 433 SystemUIQCViewController qcController = (SystemUIQCViewController) controller; 434 qcController.setActionListener(mQCActionListener); 435 mQCViewControllers.add(qcController); 436 } 437 } 438 } 439 findViewsOfType(ViewGroup rootView, Class<T> clazz)440 private <T extends View> List<T> findViewsOfType(ViewGroup rootView, Class<T> clazz) { 441 List<T> views = new ArrayList<>(); 442 for (int i = 0; i < rootView.getChildCount(); i++) { 443 View v = rootView.getChildAt(i); 444 if (clazz.isInstance(v)) { 445 views.add(clazz.cast(v)); 446 } else if (v instanceof ViewGroup) { 447 views.addAll(findViewsOfType((ViewGroup) v, clazz)); 448 } 449 } 450 return views; 451 } 452 setAnimatedStatusIconHighlightedStatus(boolean isHighlighted)453 private void setAnimatedStatusIconHighlightedStatus(boolean isHighlighted) { 454 if (mView instanceof AnimatedStatusIcon) { 455 ((AnimatedStatusIcon) mView).setIconHighlighted(isHighlighted); 456 } 457 } 458 isPanelShowing()459 private boolean isPanelShowing() { 460 return mPanel != null && mPanel.isShowing(); 461 } 462 isDeviceSetupForUser()463 private boolean isDeviceSetupForUser() { 464 return mCarDeviceProvisionedController.isCurrentUserSetup() 465 && !mCarDeviceProvisionedController.isCurrentUserSetupInProgress(); 466 } 467 468 private static class DrawableViewOutlineProvider extends ViewOutlineProvider { 469 private final Drawable mDrawable; 470 DrawableViewOutlineProvider(Drawable drawable)471 private DrawableViewOutlineProvider(Drawable drawable) { 472 mDrawable = drawable; 473 } 474 475 @Override getOutline(View view, Outline outline)476 public void getOutline(View view, Outline outline) { 477 if (mDrawable != null) { 478 mDrawable.getOutline(outline); 479 } else { 480 outline.setRect(0, 0, view.getWidth(), view.getHeight()); 481 outline.setAlpha(0.0f); 482 } 483 } 484 } 485 486 /** Daggerized builder for StatusIconPanelViewController */ 487 public static class Builder { 488 private final Context mContext; 489 private final UserTracker mUserTracker; 490 private final CarServiceProvider mCarServiceProvider; 491 private final BroadcastDispatcher mBroadcastDispatcher; 492 private final ConfigurationController mConfigurationController; 493 private final CarDeviceProvisionedController mCarDeviceProvisionedController; 494 private final CarSystemBarElementInitializer mCarSystemBarElementInitializer; 495 496 private int mXOffset = 0; 497 private int mYOffset; 498 private int mGravity = Gravity.TOP | Gravity.START; 499 private boolean mIsDisabledWhileDriving = false; 500 private boolean mIsDisabledWhileUnprovisioned = false; 501 private boolean mShowAsDropDown = true; 502 503 @Inject Builder( Context context, UserTracker userTracker, CarServiceProvider carServiceProvider, BroadcastDispatcher broadcastDispatcher, ConfigurationController configurationController, CarDeviceProvisionedController deviceProvisionedController, CarSystemBarElementInitializer elementInitializer)504 public Builder( 505 Context context, 506 UserTracker userTracker, 507 CarServiceProvider carServiceProvider, 508 BroadcastDispatcher broadcastDispatcher, 509 ConfigurationController configurationController, 510 CarDeviceProvisionedController deviceProvisionedController, 511 CarSystemBarElementInitializer elementInitializer) { 512 mContext = context; 513 mUserTracker = userTracker; 514 mCarServiceProvider = carServiceProvider; 515 mBroadcastDispatcher = broadcastDispatcher; 516 mConfigurationController = configurationController; 517 mCarDeviceProvisionedController = deviceProvisionedController; 518 mCarSystemBarElementInitializer = elementInitializer; 519 520 int panelMarginTop = mContext.getResources().getDimensionPixelSize( 521 R.dimen.car_status_icon_panel_margin_top); 522 int topSystemBarHeight = mContext.getResources().getDimensionPixelSize( 523 R.dimen.car_top_system_bar_height); 524 // TODO(b/202563671): remove mYOffset when the PopupWindow API is updated. 525 mYOffset = panelMarginTop - topSystemBarHeight; 526 } 527 528 /** Set the panel offset in the x direction by a specified number of pixels. */ setXOffset(int offset)529 public Builder setXOffset(int offset) { 530 mXOffset = offset; 531 return this; 532 } 533 534 /** Set the panel offset in the y direction by a specified number of pixels. */ setYOffset(int offset)535 public Builder setYOffset(int offset) { 536 mYOffset = offset; 537 return this; 538 } 539 540 /** Set the panel's gravity - by default the gravity will be `Gravity.TOP | Gravity.START`*/ setGravity(int gravity)541 public Builder setGravity(int gravity) { 542 mGravity = gravity; 543 return this; 544 } 545 546 /** Set whether the panel should be shown while driving or not - defaults to false. */ setDisabledWhileDriving(boolean disabled)547 public Builder setDisabledWhileDriving(boolean disabled) { 548 mIsDisabledWhileDriving = disabled; 549 return this; 550 } 551 552 /** 553 * Sets whether the panel should be disabled when the device is unprovisioned - defaults 554 * to false 555 */ setDisabledWhileUnprovisioned(boolean disabled)556 public Builder setDisabledWhileUnprovisioned(boolean disabled) { 557 mIsDisabledWhileUnprovisioned = disabled; 558 return this; 559 } 560 561 /** 562 * Set whether the panel should be shown as a dropdown (vs. at a specific location) 563 * - defaults to true. 564 */ setShowAsDropDown(boolean dropDown)565 public Builder setShowAsDropDown(boolean dropDown) { 566 mShowAsDropDown = dropDown; 567 return this; 568 } 569 570 /** 571 * Builds the controller with the required parameters of anchor view, panel layout resource, 572 * and panel width resources. 573 */ build(View anchorView, @LayoutRes int layoutRes, @DimenRes int widthRes)574 public StatusIconPanelViewController build(View anchorView, @LayoutRes int layoutRes, 575 @DimenRes int widthRes) { 576 return new StatusIconPanelViewController(mContext, mUserTracker, mCarServiceProvider, 577 mBroadcastDispatcher, mConfigurationController, mCarDeviceProvisionedController, 578 mCarSystemBarElementInitializer, anchorView, layoutRes, widthRes, mXOffset, 579 mYOffset, mGravity, mIsDisabledWhileDriving, mIsDisabledWhileUnprovisioned, 580 mShowAsDropDown); 581 } 582 } 583 } 584