blob: cde80e66ba261a1eb5a80cb18330f39d08de4a9b [file] [log] [blame]
/*
* Copyright (C) 2014 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.qs;
import static com.android.systemui.util.Utils.useQsMediaPlayer;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.util.AttributeSet;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import androidx.annotation.VisibleForTesting;
import com.android.internal.logging.UiEventLogger;
import com.android.internal.widget.RemeasuringLinearLayout;
import com.android.systemui.R;
import com.android.systemui.plugins.qs.DetailAdapter;
import com.android.systemui.plugins.qs.QSTile;
import com.android.systemui.settings.brightness.BrightnessSlider;
import com.android.systemui.statusbar.policy.BrightnessMirrorController;
import com.android.systemui.tuner.TunerService;
import com.android.systemui.tuner.TunerService.Tunable;
import com.android.systemui.util.animation.UniqueObjectHostView;
import java.util.ArrayList;
import java.util.List;
/** View that represents the quick settings tile panel (when expanded/pulled down). **/
public class QSPanel extends LinearLayout implements Tunable {
public static final String QS_SHOW_BRIGHTNESS = "qs_show_brightness";
public static final String QS_SHOW_HEADER = "qs_show_header";
private static final String TAG = "QSPanel";
protected final Context mContext;
private final int mMediaTopMargin;
private final int mMediaTotalBottomMargin;
/**
* The index where the content starts that needs to be moved between parents
*/
private int mMovableContentStartIndex;
@Nullable
protected View mBrightnessView;
@Nullable
protected BrightnessSlider mToggleSliderController;
private final H mHandler = new H();
/** Whether or not the QS media player feature is enabled. */
protected boolean mUsingMediaPlayer;
protected boolean mExpanded;
protected boolean mListening;
private QSDetail.Callback mCallback;
protected QSTileHost mHost;
private final List<OnConfigurationChangedListener> mOnConfigurationChangedListeners =
new ArrayList<>();
@Nullable
protected View mSecurityFooter;
@Nullable
protected View mFooter;
@Nullable
private ViewGroup mHeaderContainer;
private PageIndicator mFooterPageIndicator;
private int mContentMarginStart;
private int mContentMarginEnd;
private boolean mUsingHorizontalLayout;
private Record mDetailRecord;
private BrightnessMirrorController mBrightnessMirrorController;
private LinearLayout mHorizontalLinearLayout;
protected LinearLayout mHorizontalContentContainer;
protected QSTileLayout mTileLayout;
public QSPanel(Context context, AttributeSet attrs) {
super(context, attrs);
mUsingMediaPlayer = useQsMediaPlayer(context);
mMediaTotalBottomMargin = getResources().getDimensionPixelSize(
R.dimen.quick_settings_bottom_margin_media);
mMediaTopMargin = getResources().getDimensionPixelSize(
R.dimen.qs_tile_margin_vertical);
mContext = context;
setOrientation(VERTICAL);
mMovableContentStartIndex = getChildCount();
}
void initialize() {
mTileLayout = getOrCreateTileLayout();
if (mUsingMediaPlayer) {
mHorizontalLinearLayout = new RemeasuringLinearLayout(mContext);
mHorizontalLinearLayout.setOrientation(LinearLayout.HORIZONTAL);
mHorizontalLinearLayout.setClipChildren(false);
mHorizontalLinearLayout.setClipToPadding(false);
mHorizontalContentContainer = new RemeasuringLinearLayout(mContext);
mHorizontalContentContainer.setOrientation(LinearLayout.VERTICAL);
mHorizontalContentContainer.setClipChildren(true);
mHorizontalContentContainer.setClipToPadding(false);
LayoutParams lp = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1);
int marginSize = (int) mContext.getResources().getDimension(R.dimen.qs_media_padding);
lp.setMarginStart(0);
lp.setMarginEnd(marginSize);
lp.gravity = Gravity.CENTER_VERTICAL;
mHorizontalLinearLayout.addView(mHorizontalContentContainer, lp);
lp = new LayoutParams(LayoutParams.MATCH_PARENT, 0, 1);
addView(mHorizontalLinearLayout, lp);
}
}
/**
* Add brightness view above the tile layout.
*
* Used to add the brightness slider after construction.
*/
public void setBrightnessView(@NonNull View view) {
if (mBrightnessView != null) {
removeView(mBrightnessView);
mMovableContentStartIndex--;
}
addView(view, 0);
mBrightnessView = view;
setBrightnessViewMargin();
mMovableContentStartIndex++;
}
private void setBrightnessViewMargin() {
if (mBrightnessView != null) {
MarginLayoutParams lp = (MarginLayoutParams) mBrightnessView.getLayoutParams();
lp.topMargin = mContext.getResources()
.getDimensionPixelSize(R.dimen.qs_brightness_margin_top);
lp.bottomMargin = mContext.getResources()
.getDimensionPixelSize(R.dimen.qs_brightness_margin_bottom);
mBrightnessView.setLayoutParams(lp);
}
}
/** */
public QSTileLayout getOrCreateTileLayout() {
if (mTileLayout == null) {
mTileLayout = (QSTileLayout) LayoutInflater.from(mContext)
.inflate(R.layout.qs_paged_tile_layout, this, false);
}
return mTileLayout;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (mTileLayout instanceof PagedTileLayout) {
// Since PageIndicator gets measured before PagedTileLayout, we preemptively set the
// # of pages before the measurement pass so PageIndicator is measured appropriately
if (mFooterPageIndicator != null) {
mFooterPageIndicator.setNumPages(((PagedTileLayout) mTileLayout).getNumPages());
}
// In landscape, mTileLayout's parent is not the panel but a view that contains the
// tile layout and the media controls.
if (((View) mTileLayout).getParent() == this) {
// Allow the UI to be as big as it want's to, we're in a scroll view
int newHeight = 10000;
int availableHeight = MeasureSpec.getSize(heightMeasureSpec);
int excessHeight = newHeight - availableHeight;
// Measure with EXACTLY. That way, The content will only use excess height and will
// be measured last, after other views and padding is accounted for. This only
// works because our Layouts in here remeasure themselves with the exact content
// height.
heightMeasureSpec = MeasureSpec.makeMeasureSpec(newHeight, MeasureSpec.EXACTLY);
((PagedTileLayout) mTileLayout).setExcessHeight(excessHeight);
}
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// We want all the logic of LinearLayout#onMeasure, and for it to assign the excess space
// not used by the other children to PagedTileLayout. However, in this case, LinearLayout
// assumes that PagedTileLayout would use all the excess space. This is not the case as
// PagedTileLayout height is quantized (because it shows a certain number of rows).
// Therefore, after everything is measured, we need to make sure that we add up the correct
// total height
int height = getPaddingBottom() + getPaddingTop();
int numChildren = getChildCount();
for (int i = 0; i < numChildren; i++) {
View child = getChildAt(i);
if (child.getVisibility() != View.GONE) {
height += child.getMeasuredHeight();
MarginLayoutParams layoutParams = (MarginLayoutParams) child.getLayoutParams();
height += layoutParams.topMargin + layoutParams.bottomMargin;
}
}
setMeasuredDimension(getMeasuredWidth(), height);
}
protected String getDumpableTag() {
return TAG;
}
@Override
public void onTuningChanged(String key, String newValue) {
if (QS_SHOW_BRIGHTNESS.equals(key) && mBrightnessView != null) {
updateViewVisibilityForTuningValue(mBrightnessView, newValue);
}
}
private void updateViewVisibilityForTuningValue(View view, @Nullable String newValue) {
view.setVisibility(TunerService.parseIntegerSwitch(newValue, true) ? VISIBLE : GONE);
}
/** */
public void openDetails(QSTile tile) {
// If there's no tile with that name (as defined in QSFactoryImpl or other QSFactory),
// QSFactory will not be able to create a tile and getTile will return null
if (tile != null) {
showDetailAdapter(true, tile.getDetailAdapter(), new int[]{getWidth() / 2, 0});
}
}
@Nullable
View getBrightnessView() {
return mBrightnessView;
}
public void setCallback(QSDetail.Callback callback) {
mCallback = callback;
}
/**
* Links the footer's page indicator, which is used in landscape orientation to save space.
*
* @param pageIndicator indicator to use for page scrolling
*/
public void setFooterPageIndicator(PageIndicator pageIndicator) {
if (mTileLayout instanceof PagedTileLayout) {
mFooterPageIndicator = pageIndicator;
updatePageIndicator();
}
}
private void updatePageIndicator() {
if (mTileLayout instanceof PagedTileLayout) {
if (mFooterPageIndicator != null) {
mFooterPageIndicator.setVisibility(View.GONE);
((PagedTileLayout) mTileLayout).setPageIndicator(mFooterPageIndicator);
}
}
}
public QSTileHost getHost() {
return mHost;
}
public void updateResources() {
updatePadding();
updatePageIndicator();
setBrightnessViewMargin();
if (mTileLayout != null) {
mTileLayout.updateResources();
}
}
protected void updatePadding() {
final Resources res = mContext.getResources();
int padding = res.getDimensionPixelSize(R.dimen.qs_panel_padding_top);
setPaddingRelative(getPaddingStart(),
padding,
getPaddingEnd(),
res.getDimensionPixelSize(R.dimen.qs_panel_padding_bottom));
}
void addOnConfigurationChangedListener(OnConfigurationChangedListener listener) {
mOnConfigurationChangedListeners.add(listener);
}
void removeOnConfigurationChangedListener(OnConfigurationChangedListener listener) {
mOnConfigurationChangedListeners.remove(listener);
}
@Override
protected void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
mOnConfigurationChangedListeners.forEach(
listener -> listener.onConfigurationChange(newConfig));
switchSecurityFooter();
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mFooter = findViewById(R.id.qs_footer);
}
private void updateHorizontalLinearLayoutMargins() {
if (mHorizontalLinearLayout != null && !displayMediaMarginsOnMedia()) {
LayoutParams lp = (LayoutParams) mHorizontalLinearLayout.getLayoutParams();
lp.bottomMargin = Math.max(mMediaTotalBottomMargin - getPaddingBottom(), 0);
mHorizontalLinearLayout.setLayoutParams(lp);
}
}
/**
* @return true if the margin bottom of the media view should be on the media host or false
* if they should be on the HorizontalLinearLayout. Returning {@code false} is useful
* to visually center the tiles in the Media view, which doesn't work when the
* expanded panel actually scrolls.
*/
protected boolean displayMediaMarginsOnMedia() {
return true;
}
/**
* @return true if the media view needs margin on the top to separate it from the qs tiles
*/
protected boolean mediaNeedsTopMargin() {
return false;
}
private boolean needsDynamicRowsAndColumns() {
return true;
}
private void switchAllContentToParent(ViewGroup parent, QSTileLayout newLayout) {
int index = parent == this ? mMovableContentStartIndex : 0;
// Let's first move the tileLayout to the new parent, since that should come first.
switchToParent((View) newLayout, parent, index);
index++;
if (mFooter != null) {
// Then the footer with the settings
switchToParent(mFooter, parent, index);
index++;
}
// The security footer is switched on orientation changes
}
private void switchSecurityFooter() {
if (mSecurityFooter != null) {
if (mContext.getResources().getConfiguration().orientation
== Configuration.ORIENTATION_LANDSCAPE && mHeaderContainer != null) {
// Adding the security view to the header, that enables us to avoid scrolling
switchToParent(mSecurityFooter, mHeaderContainer, 0);
} else {
// Where should this go? If there's media, right before it. Otherwise, at the end.
View mediaView = findViewByPredicate(v -> v instanceof UniqueObjectHostView);
int index = -1;
if (mediaView != null) {
index = indexOfChild(mediaView);
}
if (mSecurityFooter.getParent() == this && indexOfChild(mSecurityFooter) < index) {
// When we remove the securityFooter to rearrange, the index of media will go
// down by one, so we correct it
index--;
}
switchToParent(mSecurityFooter, this, index);
}
}
}
private void switchToParent(View child, ViewGroup parent, int index) {
switchToParent(child, parent, index, getDumpableTag());
}
/** Call when orientation has changed and MediaHost needs to be adjusted. */
private void reAttachMediaHost(ViewGroup hostView, boolean horizontal) {
if (!mUsingMediaPlayer) {
return;
}
ViewGroup newParent = horizontal ? mHorizontalLinearLayout : this;
ViewGroup currentParent = (ViewGroup) hostView.getParent();
if (currentParent != newParent) {
if (currentParent != null) {
currentParent.removeView(hostView);
}
newParent.addView(hostView);
LinearLayout.LayoutParams layoutParams = (LayoutParams) hostView.getLayoutParams();
layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT;
layoutParams.width = horizontal ? 0 : ViewGroup.LayoutParams.MATCH_PARENT;
layoutParams.weight = horizontal ? 1f : 0;
// Add any bottom margin, such that the total spacing is correct. This is only
// necessary if the view isn't horizontal, since otherwise the padding is
// carried in the parent of this view (to ensure correct vertical alignment)
layoutParams.bottomMargin = !horizontal || displayMediaMarginsOnMedia()
? Math.max(mMediaTotalBottomMargin - getPaddingBottom(), 0) : 0;
layoutParams.topMargin = mediaNeedsTopMargin() && !horizontal
? mMediaTopMargin : 0;
}
}
public void setExpanded(boolean expanded) {
if (mExpanded == expanded) return;
mExpanded = expanded;
if (!mExpanded && mTileLayout instanceof PagedTileLayout) {
((PagedTileLayout) mTileLayout).setCurrentItem(0, false);
}
}
public void setPageListener(final PagedTileLayout.PageListener pageListener) {
if (mTileLayout instanceof PagedTileLayout) {
((PagedTileLayout) mTileLayout).setPageListener(pageListener);
}
}
public boolean isExpanded() {
return mExpanded;
}
/** */
public void setListening(boolean listening) {
mListening = listening;
}
public void showDetailAdapter(boolean show, DetailAdapter adapter, int[] locationInWindow) {
int xInWindow = locationInWindow[0];
int yInWindow = locationInWindow[1];
((View) getParent()).getLocationInWindow(locationInWindow);
Record r = new Record();
r.detailAdapter = adapter;
r.x = xInWindow - locationInWindow[0];
r.y = yInWindow - locationInWindow[1];
locationInWindow[0] = xInWindow;
locationInWindow[1] = yInWindow;
showDetail(show, r);
}
protected void showDetail(boolean show, Record r) {
mHandler.obtainMessage(H.SHOW_DETAIL, show ? 1 : 0, 0, r).sendToTarget();
}
protected void drawTile(QSPanelControllerBase.TileRecord r, QSTile.State state) {
r.tileView.onStateChanged(state);
}
protected QSEvent openPanelEvent() {
return QSEvent.QS_PANEL_EXPANDED;
}
protected QSEvent closePanelEvent() {
return QSEvent.QS_PANEL_COLLAPSED;
}
protected QSEvent tileVisibleEvent() {
return QSEvent.QS_TILE_VISIBLE;
}
protected boolean shouldShowDetail() {
return mExpanded;
}
void addTile(QSPanelControllerBase.TileRecord tileRecord) {
final QSTile.Callback callback = new QSTile.Callback() {
@Override
public void onStateChanged(QSTile.State state) {
drawTile(tileRecord, state);
}
@Override
public void onShowDetail(boolean show) {
// Both the collapsed and full QS panels get this callback, this check determines
// which one should handle showing the detail.
if (shouldShowDetail()) {
QSPanel.this.showDetail(show, tileRecord);
}
}
@Override
public void onToggleStateChanged(boolean state) {
if (mDetailRecord == tileRecord) {
fireToggleStateChanged(state);
}
}
@Override
public void onScanStateChanged(boolean state) {
tileRecord.scanState = state;
if (mDetailRecord == tileRecord) {
fireScanStateChanged(tileRecord.scanState);
}
}
@Override
public void onAnnouncementRequested(CharSequence announcement) {
if (announcement != null) {
mHandler.obtainMessage(H.ANNOUNCE_FOR_ACCESSIBILITY, announcement)
.sendToTarget();
}
}
};
tileRecord.tile.addCallback(callback);
tileRecord.callback = callback;
tileRecord.tileView.init(tileRecord.tile);
tileRecord.tile.refreshState();
if (mTileLayout != null) {
mTileLayout.addTile(tileRecord);
}
}
void removeTile(QSPanelControllerBase.TileRecord tileRecord) {
mTileLayout.removeTile(tileRecord);
}
void closeDetail() {
showDetail(false, mDetailRecord);
}
public int getGridHeight() {
return getMeasuredHeight();
}
protected void handleShowDetail(Record r, boolean show) {
if (r instanceof QSPanelControllerBase.TileRecord) {
handleShowDetailTile((QSPanelControllerBase.TileRecord) r, show);
} else {
int x = 0;
int y = 0;
if (r != null) {
x = r.x;
y = r.y;
}
handleShowDetailImpl(r, show, x, y);
}
}
private void handleShowDetailTile(QSPanelControllerBase.TileRecord r, boolean show) {
if ((mDetailRecord != null) == show && mDetailRecord == r) return;
if (show) {
r.detailAdapter = r.tile.getDetailAdapter();
if (r.detailAdapter == null) return;
}
r.tile.setDetailListening(show);
int x = r.tileView.getLeft() + r.tileView.getWidth() / 2;
int y = r.tileView.getDetailY() + mTileLayout.getOffsetTop(r) + getTop();
handleShowDetailImpl(r, show, x, y);
}
private void handleShowDetailImpl(Record r, boolean show, int x, int y) {
setDetailRecord(show ? r : null);
fireShowingDetail(show ? r.detailAdapter : null, x, y);
}
protected void setDetailRecord(Record r) {
if (r == mDetailRecord) return;
mDetailRecord = r;
final boolean scanState = mDetailRecord instanceof QSPanelControllerBase.TileRecord
&& ((QSPanelControllerBase.TileRecord) mDetailRecord).scanState;
fireScanStateChanged(scanState);
}
private void fireShowingDetail(DetailAdapter detail, int x, int y) {
if (mCallback != null) {
mCallback.onShowingDetail(detail, x, y);
}
}
private void fireToggleStateChanged(boolean state) {
if (mCallback != null) {
mCallback.onToggleStateChanged(state);
}
}
private void fireScanStateChanged(boolean state) {
if (mCallback != null) {
mCallback.onScanStateChanged(state);
}
}
QSTileLayout getTileLayout() {
return mTileLayout;
}
/** */
public void setContentMargins(int startMargin, int endMargin, ViewGroup mediaHostView) {
// Only some views actually want this content padding, others want to go all the way
// to the edge like the brightness slider
mContentMarginStart = startMargin;
mContentMarginEnd = endMargin;
updateMediaHostContentMargins(mediaHostView);
}
/**
* Update the margins of the media hosts
*/
protected void updateMediaHostContentMargins(ViewGroup mediaHostView) {
if (mUsingMediaPlayer) {
int marginStart = 0;
int marginEnd = 0;
if (mUsingHorizontalLayout) {
marginEnd = mContentMarginEnd;
}
updateMargins(mediaHostView, marginStart, marginEnd);
}
}
/**
* Update the margins of a view.
*
* @param view the view to adjust
* @param start the start margin to set
* @param end the end margin to set
*/
protected void updateMargins(View view, int start, int end) {
LayoutParams lp = (LayoutParams) view.getLayoutParams();
if (lp != null) {
lp.setMarginStart(start);
lp.setMarginEnd(end);
view.setLayoutParams(lp);
}
}
/**
* Set the header container of quick settings.
*/
public void setHeaderContainer(@NonNull ViewGroup headerContainer) {
mHeaderContainer = headerContainer;
}
public boolean isListening() {
return mListening;
}
public void setSecurityFooter(View view) {
mSecurityFooter = view;
switchSecurityFooter();
}
protected void setPageMargin(int pageMargin) {
if (mTileLayout instanceof PagedTileLayout) {
((PagedTileLayout) mTileLayout).setPageMargin(pageMargin);
}
}
void setUsingHorizontalLayout(boolean horizontal, ViewGroup mediaHostView, boolean force) {
if (horizontal != mUsingHorizontalLayout || force) {
mUsingHorizontalLayout = horizontal;
ViewGroup newParent = horizontal ? mHorizontalContentContainer : this;
switchAllContentToParent(newParent, mTileLayout);
reAttachMediaHost(mediaHostView, horizontal);
if (needsDynamicRowsAndColumns()) {
mTileLayout.setMinRows(horizontal ? 2 : 1);
mTileLayout.setMaxColumns(horizontal ? 2 : 4);
}
updateMargins(mediaHostView);
mHorizontalLinearLayout.setVisibility(horizontal ? View.VISIBLE : View.GONE);
}
}
private void updateMargins(ViewGroup mediaHostView) {
updateMediaHostContentMargins(mediaHostView);
updateHorizontalLinearLayoutMargins();
updatePadding();
}
private class H extends Handler {
private static final int SHOW_DETAIL = 1;
private static final int SET_TILE_VISIBILITY = 2;
private static final int ANNOUNCE_FOR_ACCESSIBILITY = 3;
@Override
public void handleMessage(Message msg) {
if (msg.what == SHOW_DETAIL) {
handleShowDetail((Record) msg.obj, msg.arg1 != 0);
} else if (msg.what == ANNOUNCE_FOR_ACCESSIBILITY) {
announceForAccessibility((CharSequence) msg.obj);
}
}
}
protected static class Record {
DetailAdapter detailAdapter;
int x;
int y;
}
public interface QSTileLayout {
/** */
default void saveInstanceState(Bundle outState) {}
/** */
default void restoreInstanceState(Bundle savedInstanceState) {}
/** */
void addTile(QSPanelControllerBase.TileRecord tile);
/** */
void removeTile(QSPanelControllerBase.TileRecord tile);
/** */
int getOffsetTop(QSPanelControllerBase.TileRecord tile);
/** */
boolean updateResources();
/** */
void setListening(boolean listening, UiEventLogger uiEventLogger);
/**
* Sets the minimum number of rows to show
*
* @param minRows the minimum.
*/
default boolean setMinRows(int minRows) {
return false;
}
/**
* Sets the max number of columns to show
*
* @param maxColumns the maximum
*
* @return true if the number of visible columns has changed.
*/
default boolean setMaxColumns(int maxColumns) {
return false;
}
/**
* Sets the expansion value and proposedTranslation to panel.
*/
default void setExpansion(float expansion, float proposedTranslation) {}
int getNumVisibleTiles();
}
interface OnConfigurationChangedListener {
void onConfigurationChange(Configuration newConfig);
}
@VisibleForTesting
static void switchToParent(View child, ViewGroup parent, int index, String tag) {
if (parent == null) {
Log.w(tag, "Trying to move view to null parent",
new IllegalStateException());
return;
}
ViewGroup currentParent = (ViewGroup) child.getParent();
if (currentParent != parent) {
if (currentParent != null) {
currentParent.removeView(child);
}
parent.addView(child, index);
return;
}
// Same parent, we are just changing indices
int currentIndex = parent.indexOfChild(child);
if (currentIndex == index) {
// We want to be in the same place. Nothing to do here
return;
}
parent.removeView(child);
parent.addView(child, index);
}
}