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