/* * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.car.statusicon; import static android.view.WindowManager.LayoutParams.TYPE_SYSTEM_DIALOG; import static android.widget.ListPopupWindow.WRAP_CONTENT; import android.annotation.DimenRes; import android.annotation.LayoutRes; import android.app.PendingIntent; import android.car.app.CarActivityManager; import android.car.drivingstate.CarUxRestrictions; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.graphics.Outline; import android.graphics.drawable.Drawable; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewOutlineProvider; import android.view.ViewTreeObserver; import android.view.WindowManager; import android.widget.PopupWindow; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; import com.android.car.qc.QCItem; import com.android.car.qc.view.QCView; import com.android.car.ui.FocusParkingView; import com.android.car.ui.utils.CarUxRestrictionsUtil; import com.android.systemui.R; import com.android.systemui.broadcast.BroadcastDispatcher; import com.android.systemui.car.CarDeviceProvisionedController; import com.android.systemui.car.CarServiceProvider; import com.android.systemui.car.qc.SystemUIQCViewController; import com.android.systemui.car.systembar.element.CarSystemBarElementController; import com.android.systemui.car.systembar.element.CarSystemBarElementInitializer; import com.android.systemui.car.users.CarSystemUIUserUtil; import com.android.systemui.settings.UserTracker; import com.android.systemui.statusbar.policy.ConfigurationController; import com.android.systemui.util.ViewController; import java.util.ArrayList; import java.util.List; import javax.inject.Inject; /** * A controller for a panel view associated with a status icon. */ public class StatusIconPanelViewController extends ViewController { private final Context mContext; private final UserTracker mUserTracker; private final CarServiceProvider mCarServiceProvider; private final BroadcastDispatcher mBroadcastDispatcher; private final ConfigurationController mConfigurationController; private final CarDeviceProvisionedController mCarDeviceProvisionedController; private final CarSystemBarElementInitializer mCarSystemBarElementInitializer; private final String mIdentifier; @LayoutRes private final int mPanelLayoutRes; @DimenRes private final int mPanelWidthRes; private final int mXOffsetPixel; private final int mYOffsetPixel; private final int mPanelGravity; private final boolean mIsDisabledWhileDriving; private final boolean mIsDisabledWhileUnprovisioned; private final boolean mShowAsDropDown; private final ArrayList mQCViewControllers = new ArrayList<>(); private PopupWindow mPanel; private ViewGroup mPanelContent; private CarUxRestrictionsUtil mCarUxRestrictionsUtil; private CarActivityManager mCarActivityManager; private float mDimValue = -1.0f; private View.OnClickListener mOnClickListener; private final ConfigurationController.ConfigurationListener mConfigurationListener = new ConfigurationController.ConfigurationListener() { @Override public void onLayoutDirectionChanged(boolean isLayoutRtl) { recreatePanel(); } }; private final View.OnLayoutChangeListener mPanelContentLayoutChangeListener = new View.OnLayoutChangeListener() { @Override public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { if (mPanelContent != null) { mPanelContent.invalidateOutline(); } } }; private final CarUxRestrictionsUtil.OnUxRestrictionsChangedListener mUxRestrictionsChangedListener = new CarUxRestrictionsUtil.OnUxRestrictionsChangedListener() { @Override public void onRestrictionsChanged(@NonNull CarUxRestrictions carUxRestrictions) { if (mIsDisabledWhileDriving && carUxRestrictions.isRequiresDistractionOptimization() && isPanelShowing()) { mPanel.dismiss(); } } }; private final CarServiceProvider.CarServiceOnConnectedListener mCarServiceOnConnectedListener = car -> { mCarActivityManager = car.getCarManager(CarActivityManager.class); }; private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); boolean isIntentFromSelf = intent.getIdentifier() != null && intent.getIdentifier().equals(mIdentifier); if (Intent.ACTION_CLOSE_SYSTEM_DIALOGS.equals(action) && !isIntentFromSelf && isPanelShowing()) { mPanel.dismiss(); } } }; private final UserTracker.Callback mUserTrackerCallback = new UserTracker.Callback() { @Override public void onUserChanged(int newUser, Context userContext) { mBroadcastDispatcher.unregisterReceiver(mBroadcastReceiver); mBroadcastDispatcher.registerReceiver(mBroadcastReceiver, new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS), /* executor= */ null, mUserTracker.getUserHandle()); } }; private final ViewTreeObserver.OnGlobalFocusChangeListener mFocusChangeListener = (oldFocus, newFocus) -> { if (isPanelShowing() && oldFocus != null && newFocus instanceof FocusParkingView) { // When nudging out of the panel, RotaryService will focus on the // FocusParkingView to clear the focus highlight. When this occurs, dismiss the // panel. mPanel.dismiss(); } }; private final QCView.QCActionListener mQCActionListener = (item, action) -> { if (!isPanelShowing()) { return; } if (action instanceof PendingIntent) { if (((PendingIntent) action).isActivity()) { mPanel.dismiss(); } } else if (action instanceof QCItem.ActionHandler) { if (((QCItem.ActionHandler) action).isActivity()) { mPanel.dismiss(); } } }; private 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) { super(anchorView); mContext = context; mUserTracker = userTracker; mCarServiceProvider = carServiceProvider; mBroadcastDispatcher = broadcastDispatcher; mConfigurationController = configurationController; mCarDeviceProvisionedController = deviceProvisionedController; mCarSystemBarElementInitializer = elementInitializer; mIsDisabledWhileDriving = isDisabledWhileDriving; mIsDisabledWhileUnprovisioned = isDisabledWhileUnprovisioned; mPanelLayoutRes = layoutRes; mPanelWidthRes = widthRes; mXOffsetPixel = xOffset; mYOffsetPixel = yOffset; mPanelGravity = gravity; mShowAsDropDown = showAsDropDown; mIdentifier = Integer.toString(System.identityHashCode(this)); } @Override protected void onInit() { mOnClickListener = v -> { if (mIsDisabledWhileUnprovisioned && !isDeviceSetupForUser()) { return; } if (mIsDisabledWhileDriving && mCarUxRestrictionsUtil.getCurrentRestrictions() .isRequiresDistractionOptimization()) { dismissAllSystemDialogs(); Toast.makeText(mContext, R.string.car_ui_restricted_while_driving, Toast.LENGTH_LONG).show(); return; } if (mPanel == null && !createPanel()) { return; } if (mPanel.isShowing()) { mPanel.dismiss(); return; } // Dismiss all currently open system dialogs before opening this panel. dismissAllSystemDialogs(); registerFocusListener(true); if (CarSystemUIUserUtil.isMUMDSystemUI() && mPanelLayoutRes == R.layout.qc_profile_switcher) { // TODO(b/269490856): consider removal of UserPicker carve-outs if (mCarActivityManager != null) { mCarActivityManager.startUserPickerOnDisplay(mContext.getDisplayId()); } } else { if (mShowAsDropDown) { // TODO(b/202563671): remove yOffsetPixel when the PopupWindow API is updated. mPanel.showAsDropDown(mView, mXOffsetPixel, mYOffsetPixel, mPanelGravity); } else { int verticalGravity = mPanelGravity & Gravity.VERTICAL_GRAVITY_MASK; int animationStyle = verticalGravity == Gravity.BOTTOM ? com.android.internal.R.style.Animation_DropDownUp : com.android.internal.R.style.Animation_DropDownDown; mPanel.setAnimationStyle(animationStyle); mPanel.showAtLocation(mView, mPanelGravity, mXOffsetPixel, mYOffsetPixel); } mView.setSelected(true); setAnimatedStatusIconHighlightedStatus(true); dimBehind(mPanel); } }; mView.setOnClickListener(mOnClickListener); } @Override protected void onViewAttached() { if (mPanel == null) { createPanel(); } mBroadcastDispatcher.registerReceiver(mBroadcastReceiver, new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS), /* executor= */ null, mUserTracker.getUserHandle()); mUserTracker.addCallback(mUserTrackerCallback, mContext.getMainExecutor()); mConfigurationController.addCallback(mConfigurationListener); if (mIsDisabledWhileDriving) { mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(mContext); mCarUxRestrictionsUtil.register(mUxRestrictionsChangedListener); } mCarServiceProvider.addListener(mCarServiceOnConnectedListener); } @Override protected void onViewDetached() { reset(); if (mCarUxRestrictionsUtil != null) { mCarUxRestrictionsUtil.unregister(mUxRestrictionsChangedListener); } mCarServiceProvider.removeListener(mCarServiceOnConnectedListener); mConfigurationController.removeCallback(mConfigurationListener); mUserTracker.removeCallback(mUserTrackerCallback); mBroadcastDispatcher.unregisterReceiver(mBroadcastReceiver); } @VisibleForTesting PopupWindow getPanel() { return mPanel; } @VisibleForTesting BroadcastReceiver getBroadcastReceiver() { return mBroadcastReceiver; } @VisibleForTesting String getIdentifier() { return mIdentifier; } @VisibleForTesting View.OnClickListener getOnClickListener() { return mOnClickListener; } @VisibleForTesting ConfigurationController.ConfigurationListener getConfigurationListener() { return mConfigurationListener; } @VisibleForTesting UserTracker.Callback getUserTrackerCallback() { return mUserTrackerCallback; } @VisibleForTesting ViewTreeObserver.OnGlobalFocusChangeListener getFocusChangeListener() { return mFocusChangeListener; } @VisibleForTesting QCView.QCActionListener getQCActionListener() { return mQCActionListener; } /** * Create the PopupWindow panel and assign to {@link mPanel}. * @return true if the panel was created, false otherwise */ private boolean createPanel() { if (mPanelWidthRes == 0 || mPanelLayoutRes == 0) { return false; } int panelWidth = mContext.getResources().getDimensionPixelSize(mPanelWidthRes); Drawable panelBackgroundDrawable = mContext.getResources() .getDrawable(R.drawable.status_icon_panel_bg, mContext.getTheme()); mPanelContent = (ViewGroup) LayoutInflater.from(mContext).inflate(mPanelLayoutRes, /* root= */ null); // clip content to the panel background (to handle rounded corners) mPanelContent.setOutlineProvider(new DrawableViewOutlineProvider(panelBackgroundDrawable)); mPanelContent.setClipToOutline(true); mPanelContent.addOnLayoutChangeListener(mPanelContentLayoutChangeListener); // initialize special views initQCElementViews(mPanelContent); // initialize panel mPanel = new PopupWindow(mPanelContent, panelWidth, WRAP_CONTENT); mPanel.setBackgroundDrawable(panelBackgroundDrawable); mPanel.setWindowLayoutType(TYPE_SYSTEM_DIALOG); mPanel.setFocusable(true); mPanel.setOutsideTouchable(false); mPanel.setOnDismissListener(() -> { setAnimatedStatusIconHighlightedStatus(false); mView.setSelected(false); registerFocusListener(false); }); return true; } private void dimBehind(PopupWindow popupWindow) { View container = popupWindow.getContentView().getRootView(); WindowManager wm = mContext.getSystemService(WindowManager.class); if (wm == null) return; if (mDimValue < 0) { mDimValue = mContext.getResources().getFloat(R.dimen.car_status_icon_panel_dim); } WindowManager.LayoutParams lp = (WindowManager.LayoutParams) container.getLayoutParams(); lp.flags |= WindowManager.LayoutParams.FLAG_DIM_BEHIND; lp.dimAmount = mDimValue; wm.updateViewLayout(container, lp); } private void dismissAllSystemDialogs() { Intent intent = new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS); intent.setIdentifier(mIdentifier); mContext.sendBroadcastAsUser(intent, mUserTracker.getUserHandle()); } private void registerFocusListener(boolean register) { if (mPanelContent == null) { return; } if (register) { mPanelContent.getViewTreeObserver().addOnGlobalFocusChangeListener( mFocusChangeListener); } else { mPanelContent.getViewTreeObserver().removeOnGlobalFocusChangeListener( mFocusChangeListener); } } private void reset() { if (mPanel == null) return; mPanel.dismiss(); mPanel = null; if (mPanelContent != null) { mPanelContent.removeOnLayoutChangeListener(mPanelContentLayoutChangeListener); } mPanelContent = null; mQCViewControllers.forEach(SystemUIQCViewController::destroyQCViews); mQCViewControllers.clear(); } private void recreatePanel() { reset(); createPanel(); } private void initQCElementViews(ViewGroup rootView) { List controllers = mCarSystemBarElementInitializer.initializeCarSystemBarElements(rootView); for (CarSystemBarElementController controller : controllers) { if (controller instanceof SystemUIQCViewController) { SystemUIQCViewController qcController = (SystemUIQCViewController) controller; qcController.setActionListener(mQCActionListener); mQCViewControllers.add(qcController); } } } private List findViewsOfType(ViewGroup rootView, Class clazz) { List views = new ArrayList<>(); for (int i = 0; i < rootView.getChildCount(); i++) { View v = rootView.getChildAt(i); if (clazz.isInstance(v)) { views.add(clazz.cast(v)); } else if (v instanceof ViewGroup) { views.addAll(findViewsOfType((ViewGroup) v, clazz)); } } return views; } private void setAnimatedStatusIconHighlightedStatus(boolean isHighlighted) { if (mView instanceof AnimatedStatusIcon) { ((AnimatedStatusIcon) mView).setIconHighlighted(isHighlighted); } } private boolean isPanelShowing() { return mPanel != null && mPanel.isShowing(); } private boolean isDeviceSetupForUser() { return mCarDeviceProvisionedController.isCurrentUserSetup() && !mCarDeviceProvisionedController.isCurrentUserSetupInProgress(); } private static class DrawableViewOutlineProvider extends ViewOutlineProvider { private final Drawable mDrawable; private DrawableViewOutlineProvider(Drawable drawable) { mDrawable = drawable; } @Override public void getOutline(View view, Outline outline) { if (mDrawable != null) { mDrawable.getOutline(outline); } else { outline.setRect(0, 0, view.getWidth(), view.getHeight()); outline.setAlpha(0.0f); } } } /** Daggerized builder for StatusIconPanelViewController */ public static class Builder { private final Context mContext; private final UserTracker mUserTracker; private final CarServiceProvider mCarServiceProvider; private final BroadcastDispatcher mBroadcastDispatcher; private final ConfigurationController mConfigurationController; private final CarDeviceProvisionedController mCarDeviceProvisionedController; private final CarSystemBarElementInitializer mCarSystemBarElementInitializer; private int mXOffset = 0; private int mYOffset; private int mGravity = Gravity.TOP | Gravity.START; private boolean mIsDisabledWhileDriving = false; private boolean mIsDisabledWhileUnprovisioned = false; private boolean mShowAsDropDown = true; @Inject public Builder( Context context, UserTracker userTracker, CarServiceProvider carServiceProvider, BroadcastDispatcher broadcastDispatcher, ConfigurationController configurationController, CarDeviceProvisionedController deviceProvisionedController, CarSystemBarElementInitializer elementInitializer) { mContext = context; mUserTracker = userTracker; mCarServiceProvider = carServiceProvider; mBroadcastDispatcher = broadcastDispatcher; mConfigurationController = configurationController; mCarDeviceProvisionedController = deviceProvisionedController; mCarSystemBarElementInitializer = elementInitializer; int panelMarginTop = mContext.getResources().getDimensionPixelSize( R.dimen.car_status_icon_panel_margin_top); int topSystemBarHeight = mContext.getResources().getDimensionPixelSize( R.dimen.car_top_system_bar_height); // TODO(b/202563671): remove mYOffset when the PopupWindow API is updated. mYOffset = panelMarginTop - topSystemBarHeight; } /** Set the panel offset in the x direction by a specified number of pixels. */ public Builder setXOffset(int offset) { mXOffset = offset; return this; } /** Set the panel offset in the y direction by a specified number of pixels. */ public Builder setYOffset(int offset) { mYOffset = offset; return this; } /** Set the panel's gravity - by default the gravity will be `Gravity.TOP | Gravity.START`*/ public Builder setGravity(int gravity) { mGravity = gravity; return this; } /** Set whether the panel should be shown while driving or not - defaults to false. */ public Builder setDisabledWhileDriving(boolean disabled) { mIsDisabledWhileDriving = disabled; return this; } /** * Sets whether the panel should be disabled when the device is unprovisioned - defaults * to false */ public Builder setDisabledWhileUnprovisioned(boolean disabled) { mIsDisabledWhileUnprovisioned = disabled; return this; } /** * Set whether the panel should be shown as a dropdown (vs. at a specific location) * - defaults to true. */ public Builder setShowAsDropDown(boolean dropDown) { mShowAsDropDown = dropDown; return this; } /** * Builds the controller with the required parameters of anchor view, panel layout resource, * and panel width resources. */ public StatusIconPanelViewController build(View anchorView, @LayoutRes int layoutRes, @DimenRes int widthRes) { return new StatusIconPanelViewController(mContext, mUserTracker, mCarServiceProvider, mBroadcastDispatcher, mConfigurationController, mCarDeviceProvisionedController, mCarSystemBarElementInitializer, anchorView, layoutRes, widthRes, mXOffset, mYOffset, mGravity, mIsDisabledWhileDriving, mIsDisabledWhileUnprovisioned, mShowAsDropDown); } } }