/* * Copyright (C) 2021 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.ColorInt; 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.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.WindowManager; import android.widget.ImageView; import android.widget.PopupWindow; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; 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.CarServiceProvider; import com.android.systemui.car.qc.QCFooterButton; import com.android.systemui.car.qc.QCFooterButtonView; import com.android.systemui.car.qc.QCHeaderReadOnlyIconsContainer; import com.android.systemui.car.qc.SystemUIQCView; import com.android.systemui.car.qc.SystemUIQCViewController; import com.android.systemui.car.statusicon.ui.QCPanelReadOnlyIconsController; import com.android.systemui.car.users.CarSystemUIUserUtil; import com.android.systemui.settings.UserTracker; import com.android.systemui.statusbar.policy.ConfigurationController; import java.util.ArrayList; import javax.inject.Provider; /** * A controller for a panel view associated with a status icon. */ public class StatusIconPanelController { private static final int DEFAULT_POPUP_WINDOW_ANCHOR_GRAVITY = Gravity.TOP | Gravity.START; private final Context mContext; private final UserTracker mUserTracker; private final CarServiceProvider mCarServiceProvider; private final BroadcastDispatcher mBroadcastDispatcher; private final ConfigurationController mConfigurationController; private final Provider mQCViewControllerProvider; @Nullable private final QCPanelReadOnlyIconsController mQCPanelReadOnlyIconsController; private final String mIdentifier; private final String mIconTag; private final @ColorInt int mIconHighlightedColor; private final @ColorInt int mIconNotHighlightedColor; private final int mYOffsetPixel; private final boolean mIsDisabledWhileDriving; private final ArrayList mQCViewControllers = new ArrayList<>(); private PopupWindow mPanel; private @LayoutRes int mPanelLayoutRes; private @DimenRes int mPanelWidthRes; private ViewGroup mPanelContent; private OnQcViewsFoundListener mOnQcViewsFoundListener; private View mAnchorView; private ImageView mStatusIconView; private CarUxRestrictionsUtil mCarUxRestrictionsUtil; private CarActivityManager mCarActivityManager; private float mDimValue = -1.0f; private View.OnClickListener mOnClickListener; private boolean mIsPanelDestroyed; private final ConfigurationController.ConfigurationListener mConfigurationListener = new ConfigurationController.ConfigurationListener() { @Override public void onLayoutDirectionChanged(boolean isLayoutRtl) { recreatePanel(); } }; 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(); } } }; public StatusIconPanelController( Context context, UserTracker userTracker, CarServiceProvider carServiceProvider, BroadcastDispatcher broadcastDispatcher, ConfigurationController configurationController, Provider qcViewControllerProvider) { this(context, userTracker, carServiceProvider, broadcastDispatcher, configurationController, qcViewControllerProvider, /* isDisabledWhileDriving= */ false); } public StatusIconPanelController( Context context, UserTracker userTracker, CarServiceProvider carServiceProvider, BroadcastDispatcher broadcastDispatcher, ConfigurationController configurationController, Provider qcViewControllerProvider, boolean isDisabledWhileDriving) { this(context, userTracker, carServiceProvider, broadcastDispatcher, configurationController, qcViewControllerProvider, isDisabledWhileDriving, /* qcPanelReadOnlyIconsController= */ null); } public StatusIconPanelController( Context context, UserTracker userTracker, CarServiceProvider carServiceProvider, BroadcastDispatcher broadcastDispatcher, ConfigurationController configurationController, Provider qcViewControllerProvider, boolean isDisabledWhileDriving, QCPanelReadOnlyIconsController qcPanelReadOnlyIconsController) { mContext = context; mUserTracker = userTracker; mCarServiceProvider = carServiceProvider; mBroadcastDispatcher = broadcastDispatcher; mConfigurationController = configurationController; mQCViewControllerProvider = qcViewControllerProvider; mQCPanelReadOnlyIconsController = qcPanelReadOnlyIconsController; mIdentifier = Integer.toString(System.identityHashCode(this)); mIconTag = mContext.getResources().getString(R.string.qc_icon_tag); mIconHighlightedColor = mContext.getColor(R.color.status_icon_highlighted_color); mIconNotHighlightedColor = mContext.getColor(R.color.status_icon_not_highlighted_color); 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 mYOffsetPixel when the PopupWindow API is updated. mYOffsetPixel = panelMarginTop - topSystemBarHeight; mBroadcastDispatcher.registerReceiver(mBroadcastReceiver, new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS), /* executor= */ null, mUserTracker.getUserHandle()); mUserTracker.addCallback(mUserTrackerCallback, mContext.getMainExecutor()); mConfigurationController.addCallback(mConfigurationListener); mIsDisabledWhileDriving = isDisabledWhileDriving; if (mIsDisabledWhileDriving) { mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(mContext); mCarUxRestrictionsUtil.register(mUxRestrictionsChangedListener); } } /** * @return default Y offset in pixels that cancels out the superfluous inset automatically * applied to the panel */ public int getDefaultYOffset() { return mYOffsetPixel; } public void setOnQcViewsFoundListener(OnQcViewsFoundListener onQcViewsFoundListener) { mOnQcViewsFoundListener = onQcViewsFoundListener; } /** * A listener that can be used to attach controllers quick control panels using * {@link SystemUIQCView#getLocalQCProvider()} */ public interface OnQcViewsFoundListener { /** * This method is call up when {@link SystemUIQCView}s are found */ void qcViewsFound(ArrayList qcViews); } /** * Attaches a panel to a root view that toggles the panel visibility when clicked. * * Variant of {@link #attachPanel(View, int, int, int, int, int, boolean)} with * xOffset={@code 0}, yOffset={@link #mYOffsetPixel} & * gravity={@link #DEFAULT_POPUP_WINDOW_ANCHOR_GRAVITY} & * showAsDropDown={@code true}. */ public void attachPanel(View view, @LayoutRes int layoutRes, @DimenRes int widthRes) { attachPanel(view, layoutRes, widthRes, DEFAULT_POPUP_WINDOW_ANCHOR_GRAVITY); } /** * Attaches a panel to a root view that toggles the panel visibility when clicked. * * Variant of {@link #attachPanel(View, int, int, int, int, int, boolean)} with * xOffset={@code 0} & yOffset={@link #mYOffsetPixel} & * showAsDropDown={@code true}. */ public void attachPanel(View view, @LayoutRes int layoutRes, @DimenRes int widthRes, int gravity) { attachPanel(view, layoutRes, widthRes, /* xOffset= */ 0, mYOffsetPixel, gravity); } /** * Attaches a panel to a root view that toggles the panel visibility when clicked. * * Variant of {@link #attachPanel(View, int, int, int, int, int, boolean)} with * gravity={@link #DEFAULT_POPUP_WINDOW_ANCHOR_GRAVITY} & * showAsDropDown={@code true}. */ public void attachPanel(View view, @LayoutRes int layoutRes, @DimenRes int widthRes, int xOffset, int yOffset) { attachPanel(view, layoutRes, widthRes, xOffset, yOffset, DEFAULT_POPUP_WINDOW_ANCHOR_GRAVITY); } /** * Attaches a panel to a root view that toggles the panel visibility when clicked. * * Variant of {@link #attachPanel(View, int, int, int, int, int, boolean)} with * showAsDropDown={@code true}. */ public void attachPanel(View view, @LayoutRes int layoutRes, @DimenRes int widthRes, int xOffset, int yOffset, int gravity) { attachPanel(view, layoutRes, widthRes, xOffset, yOffset, gravity, /* showAsDropDown= */ true); } /** * Attaches a panel to a root view that toggles the panel visibility when clicked. */ public void attachPanel(View view, @LayoutRes int layoutRes, @DimenRes int widthRes, int xOffset, int yOffset, int gravity, boolean showAsDropDown) { if (mIsPanelDestroyed) { throw new IllegalStateException("Attempting to attach destroyed panel"); } mCarServiceProvider.addListener(mCarServiceOnConnectedListener); if (mAnchorView == null) { mAnchorView = view; } mPanelLayoutRes = layoutRes; mPanelWidthRes = widthRes; // Pre-create panel to improve perceived UI performance createPanel(); mOnClickListener = v -> { 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(); mQCViewControllers.forEach(controller -> controller.listen(true)); 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 (showAsDropDown) { // TODO(b/202563671): remove yOffsetPixel when the PopupWindow API is updated. mPanel.showAsDropDown(mAnchorView, xOffset, yOffset, gravity); } else { int verticalGravity = gravity & 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(mAnchorView, gravity, xOffset, yOffset); } mAnchorView.setSelected(true); highlightStatusIcon(true); setAnimatedStatusIconHighlightedStatus(true); dimBehind(mPanel); } }; mAnchorView.setOnClickListener(mOnClickListener); } /** * Cleanup listeners and reset panel. This controller instance should not be used after this * method is called. */ public void destroyPanel() { reset(); if (mCarUxRestrictionsUtil != null) { mCarUxRestrictionsUtil.unregister(mUxRestrictionsChangedListener); } mCarServiceProvider.removeListener(mCarServiceOnConnectedListener); mConfigurationController.removeCallback(mConfigurationListener); mUserTracker.removeCallback(mUserTrackerCallback); mBroadcastDispatcher.unregisterReceiver(mBroadcastReceiver); mPanelLayoutRes = 0; mIsPanelDestroyed = true; } @VisibleForTesting protected PopupWindow getPanel() { return mPanel; } @VisibleForTesting protected BroadcastReceiver getBroadcastReceiver() { return mBroadcastReceiver; } @VisibleForTesting protected String getIdentifier() { return mIdentifier; } @VisibleForTesting @ColorInt protected int getIconHighlightedColor() { return mIconHighlightedColor; } @VisibleForTesting @ColorInt protected int getIconNotHighlightedColor() { return mIconNotHighlightedColor; } @VisibleForTesting protected View.OnClickListener getOnClickListener() { return mOnClickListener; } @VisibleForTesting protected ConfigurationController.ConfigurationListener getConfigurationListener() { return mConfigurationListener; } @VisibleForTesting protected UserTracker.Callback getUserTrackerCallback() { return mUserTrackerCallback; } @VisibleForTesting protected ViewTreeObserver.OnGlobalFocusChangeListener getFocusChangeListener() { return mFocusChangeListener; } @VisibleForTesting protected QCView.QCActionListener getQCActionListener() { return mQCActionListener; } /** * Create the PopupWindow panel and assign to {@link mPanel}. * @return true if the panel was created, false otherwise */ boolean createPanel() { if (mPanelWidthRes == 0 || mPanelLayoutRes == 0) { return false; } int panelWidth = mContext.getResources().getDimensionPixelSize(mPanelWidthRes); mPanelContent = (ViewGroup) LayoutInflater.from(mContext).inflate(mPanelLayoutRes, /* root= */ null); mPanelContent.setLayoutDirection(View.LAYOUT_DIRECTION_LOCALE); findQcHeaderViews(mPanelContent); findQcViews(mPanelContent); findQcFooterViews(mPanelContent); mPanel = new PopupWindow(mPanelContent, panelWidth, WRAP_CONTENT); mPanel.setBackgroundDrawable( mContext.getResources().getDrawable(R.drawable.status_icon_panel_bg, mContext.getTheme())); mPanel.setWindowLayoutType(TYPE_SYSTEM_DIALOG); mPanel.setFocusable(true); mPanel.setOutsideTouchable(false); mPanel.setOnDismissListener(() -> { setAnimatedStatusIconHighlightedStatus(false); mAnchorView.setSelected(false); highlightStatusIcon(false); registerFocusListener(false); mQCViewControllers.forEach(controller -> controller.listen(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; mPanelContent = null; mQCViewControllers.forEach(SystemUIQCViewController::destroy); mQCViewControllers.clear(); } private void recreatePanel() { reset(); createPanel(); } private void findQcHeaderViews(ViewGroup rootView) { for (int i = 0; i < rootView.getChildCount(); i++) { View v = rootView.getChildAt(i); if (v instanceof QCHeaderReadOnlyIconsContainer) { if (mQCPanelReadOnlyIconsController != null) { mQCPanelReadOnlyIconsController.addIconViews( (QCHeaderReadOnlyIconsContainer) v, /* shouldAttachPanel= */ false); } } else if (v instanceof ViewGroup) { this.findQcHeaderViews((ViewGroup) v); } } } private void findQcViews(ViewGroup rootView) { for (int i = 0; i < rootView.getChildCount(); i++) { View v = rootView.getChildAt(i); if (v instanceof SystemUIQCView) { SystemUIQCView qcv = (SystemUIQCView) v; SystemUIQCViewController controller = mQCViewControllerProvider.get(); controller.attachView(qcv); mQCViewControllers.add(controller); qcv.setActionListener(mQCActionListener); } else if (v instanceof ViewGroup) { this.findQcViews((ViewGroup) v); } } } private void findQcFooterViews(ViewGroup rootView) { for (int i = 0; i < rootView.getChildCount(); i++) { View v = rootView.getChildAt(i); if (v instanceof QCFooterButton) { ((QCFooterButton) v).setUserTracker(mUserTracker); } else if (v instanceof QCFooterButtonView) { ((QCFooterButtonView) v).setUserTracker(mUserTracker); ((QCFooterButtonView) v).setBroadcastDispatcher(mBroadcastDispatcher); } else if (v instanceof ViewGroup) { this.findQcFooterViews((ViewGroup) v); } } } private void setAnimatedStatusIconHighlightedStatus(boolean isHighlighted) { if (mAnchorView instanceof AnimatedStatusIcon) { ((AnimatedStatusIcon) mAnchorView).setIconHighlighted(isHighlighted); } } private void highlightStatusIcon(boolean isHighlighted) { if (mStatusIconView == null) { mStatusIconView = mAnchorView.findViewWithTag(mIconTag); } if (mStatusIconView != null) { mStatusIconView.setColorFilter( isHighlighted ? mIconHighlightedColor : mIconNotHighlightedColor); } } private boolean isPanelShowing() { return mPanel != null && mPanel.isShowing(); } }