RowView.java

/*
 * Copyright 2017 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 androidx.slice.widget;

import static android.app.slice.Slice.EXTRA_RANGE_VALUE;
import static android.app.slice.Slice.HINT_NO_TINT;
import static android.app.slice.Slice.HINT_PARTIAL;
import static android.app.slice.Slice.HINT_SHORTCUT;
import static android.app.slice.Slice.SUBTYPE_MAX;
import static android.app.slice.Slice.SUBTYPE_VALUE;
import static android.app.slice.SliceItem.FORMAT_ACTION;
import static android.app.slice.SliceItem.FORMAT_IMAGE;
import static android.app.slice.SliceItem.FORMAT_INT;
import static android.app.slice.SliceItem.FORMAT_LONG;
import static android.app.slice.SliceItem.FORMAT_SLICE;
import static android.app.slice.SliceItem.FORMAT_TEXT;

import static androidx.slice.Slice.EXTRA_SELECTION;
import static androidx.slice.core.SliceHints.HINT_SELECTION_OPTION;
import static androidx.slice.core.SliceHints.ICON_IMAGE;
import static androidx.slice.core.SliceHints.SMALL_IMAGE;
import static androidx.slice.core.SliceHints.SUBTYPE_MIN;
import static androidx.slice.core.SliceHints.SUBTYPE_SELECTION_OPTION_KEY;
import static androidx.slice.core.SliceHints.SUBTYPE_SELECTION_OPTION_VALUE;
import static androidx.slice.widget.EventInfo.ACTION_TYPE_BUTTON;
import static androidx.slice.widget.EventInfo.ACTION_TYPE_SELECTION;
import static androidx.slice.widget.EventInfo.ACTION_TYPE_SLIDER;
import static androidx.slice.widget.EventInfo.ACTION_TYPE_TOGGLE;
import static androidx.slice.widget.EventInfo.ROW_TYPE_LIST;
import static androidx.slice.widget.EventInfo.ROW_TYPE_SELECTION;
import static androidx.slice.widget.EventInfo.ROW_TYPE_SLIDER;
import static androidx.slice.widget.EventInfo.ROW_TYPE_TOGGLE;
import static androidx.slice.widget.SliceView.MODE_SMALL;

import android.app.PendingIntent.CanceledException;
import android.content.Context;
import android.content.Intent;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Handler;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.text.style.StyleSpan;
import android.util.ArrayMap;
import android.util.Log;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.SeekBar;
import android.widget.Spinner;
import android.widget.TextView;

import androidx.annotation.ColorInt;
import androidx.annotation.RequiresApi;
import androidx.annotation.RestrictTo;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.slice.SliceItem;
import androidx.slice.SliceStructure;
import androidx.slice.core.SliceAction;
import androidx.slice.core.SliceActionImpl;
import androidx.slice.core.SliceQuery;
import androidx.slice.view.R;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * Row item is in small template format and can be used to construct list items for use
 * with {@link TemplateView}.
 *
 * @hide
 */
@RestrictTo(RestrictTo.Scope.LIBRARY)
@RequiresApi(19)
public class RowView extends SliceChildView implements View.OnClickListener,
        AdapterView.OnItemSelectedListener {

    private static final String TAG = "RowView";

    // The number of items that fit on the right hand side of a small slice
    // TODO: this should be based on available width
    private static final int MAX_END_ITEMS = 3;
    // How frequently (ms) intent can be sent in response to slider moving.
    private static final int SLIDER_INTERVAL = 200;

    // On versions before M, SeekBar won't render properly if stretched taller than the default
    // size.
    private static final boolean sCanSpecifyLargerRangeBarHeight =
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.M;

    private LinearLayout mRootView;
    private LinearLayout mStartContainer;
    private LinearLayout mContent;
    private TextView mPrimaryText;
    private TextView mSecondaryText;
    private TextView mLastUpdatedText;
    private View mBottomDivider;
    private View mActionDivider;
    private ArrayMap<SliceActionImpl, SliceActionView> mToggles = new ArrayMap<>();
    private ArrayMap<SliceActionImpl, SliceActionView> mActions = new ArrayMap<>();
    private LinearLayout mEndContainer;
    private View mSeeMoreView;
    private ProgressBar mRangeBar;
    private ProgressBar mActionSpinner;
    protected Set<SliceItem> mLoadingActions = new HashSet<>();
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    boolean mShowActionSpinner;
    private Spinner mSelectionSpinner;

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    int mRowIndex;
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    RowContent mRowContent;
    private SliceActionImpl mRowAction;
    private SliceItem mStartItem;
    private boolean mIsHeader;
    private List<SliceAction> mHeaderActions;
    // Indicates whether header rows can have 2 lines of subtitle text
    private boolean mAllowTwoLines;

    // Indicates if there's a slider in this row that is currently being interacted with.
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    boolean mIsRangeSliding;
    // Indicates that there was an update to the row but we skipped it while the slice was
    // being interacted with.
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    boolean mRangeHasPendingUpdate;
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    boolean mRangeUpdaterRunning;
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    Handler mHandler;
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    long mLastSentRangeUpdate;
    // TODO: mRangeValue is in 0..(mRangeMaxValue-mRangeMinValue) at initialization, and in
    //       mRangeMinValue..mRangeMaxValue after user interaction. As far as I know, this doesn't
    //       cause any incorrect behavior, but it is confusing and error-prone.
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    int mRangeValue;
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    int mRangeMinValue;
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    int mRangeMaxValue;
    private SliceItem mRangeItem;
    private SliceItem mSelectionItem;
    private ArrayList<String> mSelectionOptionKeys;
    private ArrayList<CharSequence> mSelectionOptionValues;

    private int mImageSize;
    private int mIconSize;
    // How big mRangeBar wants to be.
    private int mMeasuredRangeHeight;

    public RowView(Context context) {
        super(context);
        mIconSize = getContext().getResources().getDimensionPixelSize(R.dimen.abc_slice_icon_size);
        mImageSize = getContext().getResources().getDimensionPixelSize(
                R.dimen.abc_slice_small_image_size);
        mRootView = (LinearLayout) LayoutInflater.from(context).inflate(
                R.layout.abc_slice_small_template, this, false);
        addView(mRootView);

        mStartContainer = (LinearLayout) findViewById(R.id.icon_frame);
        mContent = (LinearLayout) findViewById(android.R.id.content);
        mPrimaryText = (TextView) findViewById(android.R.id.title);
        mSecondaryText = (TextView) findViewById(android.R.id.summary);
        mLastUpdatedText = (TextView) findViewById(R.id.last_updated);
        mBottomDivider = findViewById(R.id.bottom_divider);
        mActionDivider = findViewById(R.id.action_divider);
        mActionSpinner = findViewById(R.id.action_sent_indicator);
        SliceViewUtil.tintIndeterminateProgressBar(getContext(), mActionSpinner);
        mEndContainer = (LinearLayout) findViewById(android.R.id.widget_frame);
    }

    @Override
    public void setStyle(SliceStyle styles) {
        super.setStyle(styles);
        applyRowStyle();
    }

    private void applyRowStyle() {
        if (mSliceStyle == null || mSliceStyle.getRowStyle() == null) {
            return;
        }

        final RowStyle rowStyle = mSliceStyle.getRowStyle();
        setViewPaddingEnd(mStartContainer, rowStyle.getTitleItemEndPadding());
        setViewSidePaddings(mContent,
                rowStyle.getContentStartPadding(), rowStyle.getContentEndPadding());
        setViewSidePaddings(mEndContainer,
                rowStyle.getEndItemStartPadding(), rowStyle.getEndItemEndPadding());
        setViewSideMargins(mBottomDivider,
                rowStyle.getBottomDividerStartPadding(), rowStyle.getBottomDividerEndPadding());
        setViewHeight(mActionDivider, rowStyle.getActionDividerHeight());
    }

    private void setViewPaddingEnd(View v, int end) {
        if (v != null && end >= 0) {
            v.setPaddingRelative(v.getPaddingStart(), v.getPaddingTop(), end, v.getPaddingBottom());
        }
    }

    private void setViewSidePaddings(View v, int start, int end) {
        if (v != null && start >= 0 && end >= 0) {
            v.setPaddingRelative(start, v.getPaddingTop(), end, v.getPaddingBottom());
        }
    }

    private void setViewSideMargins(View v, int start, int end) {
        if (v != null && start >= 0 && end >= 0) {
            final MarginLayoutParams params = (MarginLayoutParams) v.getLayoutParams();
            params.setMarginStart(start);
            params.setMarginEnd(end);
            mBottomDivider.setLayoutParams(params);
        }
    }

    private void setViewHeight(View v, int height) {
        if (v != null && height >= 0) {
            final ViewGroup.LayoutParams params = v.getLayoutParams();
            params.height = height;
            v.setLayoutParams(params);
        }
    }

    @Override
    public void setInsets(int l, int t, int r, int b) {
        super.setInsets(l, t, r, b);
        setPadding(l, t, r, b);
    }

    /**
     * @return height row content (i.e. title, subtitle) without the height of the range element.
     */
    private int getRowContentHeight() {
        int rowHeight = mRowContent.getHeight(mSliceStyle, mViewPolicy);
        if (mRangeBar != null) {
            rowHeight -= mSliceStyle.getRowRangeHeight();
        }
        if (mSelectionSpinner != null) {
            rowHeight -= mSliceStyle.getRowSelectionHeight();
        }
        return rowHeight;
    }

    @Override
    public void setTint(@ColorInt int tintColor) {
        super.setTint(tintColor);
        if (mRowContent != null) {
            // TODO -- can be smarter about this
            populateViews(true);
        }
    }

    /**
     * @param actions if the actions are null then there are no header actions for this row.
     * If the actions are an empty list, then something has explicitly set that no header
     * actions should appear.
     */
    @Override
    public void setSliceActions(List<SliceAction> actions) {
        mHeaderActions = actions;
        if (mRowContent != null) {
            updateEndItems();
        }
    }

    @Override
    public void setShowLastUpdated(boolean showLastUpdated) {
        super.setShowLastUpdated(showLastUpdated);
        if (mRowContent != null) {
            populateViews(true);
        }
    }

    @Override
    public void setAllowTwoLines(boolean allowTwoLines) {
        mAllowTwoLines = allowTwoLines;
        if (mRowContent != null) {
            populateViews(true);
        }
    }

    private void measureChildWithExactHeight(View child, int widthMeasureSpec, int childHeight) {
        int heightMeasureSpec = MeasureSpec.makeMeasureSpec(childHeight + mInsetTop + mInsetBottom,
                MeasureSpec.EXACTLY);
        measureChild(child, widthMeasureSpec, heightMeasureSpec);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int childWidth = 0;

        int rowHeight = getRowContentHeight();
        if (rowHeight != 0) {
            // Might be gone if we have range / progress but nothing else
            mRootView.setVisibility(View.VISIBLE);
            measureChildWithExactHeight(mRootView, widthMeasureSpec, rowHeight);

            childWidth = mRootView.getMeasuredWidth();
        } else {
            mRootView.setVisibility(View.GONE);
        }
        if (mRangeBar != null) {
            // If we're on a platform where SeekBar can't be stretched vertically, find out the
            // exact size it would like to be so we can honor that in onLayout.
            if (sCanSpecifyLargerRangeBarHeight) {
                measureChildWithExactHeight(mRangeBar, widthMeasureSpec,
                        mSliceStyle.getRowRangeHeight());
            } else {
                measureChild(mRangeBar, widthMeasureSpec,
                        MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
            }
            // Remember the measured height later for onLayout, since super.onMeasure will overwrite
            // it.
            mMeasuredRangeHeight = mRangeBar.getMeasuredHeight();
            childWidth = Math.max(childWidth, mRangeBar.getMeasuredWidth());
        } else if (mSelectionSpinner != null) {
            measureChildWithExactHeight(mSelectionSpinner, widthMeasureSpec,
                    mSliceStyle.getRowSelectionHeight());
            childWidth = Math.max(childWidth, mSelectionSpinner.getMeasuredWidth());
        }

        childWidth = Math.max(childWidth + mInsetStart + mInsetEnd, getSuggestedMinimumWidth());
        int totalHeight = mRowContent != null ? mRowContent.getHeight(mSliceStyle, mViewPolicy) : 0;
        setMeasuredDimension(resolveSizeAndState(childWidth, widthMeasureSpec, 0),
                totalHeight + mInsetTop + mInsetBottom);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int leftPadding = getPaddingLeft();
        mRootView.layout(leftPadding, mInsetTop, mRootView.getMeasuredWidth() + leftPadding,
                getRowContentHeight() + mInsetTop);
        if (mRangeBar != null) {
            // If we're on a platform where SeekBar can't be stretched vertically, then
            // mMeasuredRangeHeight can (and probably will) be smaller than the ideal height, so we
            // need to add some padding to make mRangeBar look like it's the larger size.
            int verticalPadding = (mSliceStyle.getRowRangeHeight() - mMeasuredRangeHeight) / 2;
            int top = getRowContentHeight() + verticalPadding + mInsetTop;
            int bottom = top + mMeasuredRangeHeight;
            mRangeBar.layout(leftPadding, top, mRangeBar.getMeasuredWidth() + leftPadding, bottom);
        } else if (mSelectionSpinner != null) {
            int top = getRowContentHeight() + mInsetTop;
            int bottom = top + mSelectionSpinner.getMeasuredHeight();
            mSelectionSpinner.layout(leftPadding, top,
                    mSelectionSpinner.getMeasuredWidth() + leftPadding, bottom);
        }
    }

    /**
     * This is called when RowView is being used as a component in a large template.
     */
    @Override
    public void setSliceItem(SliceContent content, boolean isHeader, int index,
            int rowCount, SliceView.OnSliceActionListener observer) {
        setSliceActionListener(observer);

        boolean isUpdate = false;
        if (content != null && mRowContent != null && mRowContent.isValid()) {
            // Might be same slice
            SliceStructure prevSs = mRowContent != null
                    ? new SliceStructure(mRowContent.getSliceItem()) : null;
            SliceStructure newSs = new SliceStructure(content.getSliceItem().getSlice());
            boolean sameStructure = prevSs != null && prevSs.equals(newSs);
            boolean sameSliceId = prevSs != null
                    && prevSs.getUri() != null && prevSs.getUri().equals(newSs.getUri());
            isUpdate = sameSliceId && sameStructure;
        }
        mShowActionSpinner = false;
        mIsHeader = isHeader;
        mRowContent = (RowContent) content;
        mRowIndex = index;
        populateViews(isUpdate);
    }

    private void populateViews(boolean isUpdate) {
        boolean skipSliderUpdate = isUpdate && mIsRangeSliding;
        if (!skipSliderUpdate) {
            resetViewState();
        }

        if (mRowContent.getLayoutDir() != -1) {
            setLayoutDirection(mRowContent.getLayoutDir());
        }
        if (mRowContent.isDefaultSeeMore()) {
            showSeeMore();
            return;
        }
        CharSequence contentDescr = mRowContent.getContentDescription();
        if (contentDescr != null) {
            mContent.setContentDescription(contentDescr);
        }
        mStartItem = mRowContent.getStartItem();
        boolean showStart = mStartItem != null && (mRowIndex > 0 || mRowContent.hasTitleItems());
        if (showStart) {
            showStart = addItem(mStartItem, mTintColor, true /* isStart */);
        }
        mStartContainer.setVisibility(showStart ? View.VISIBLE : View.GONE);

        final SliceItem titleItem = mRowContent.getTitleItem();
        if (titleItem != null) {
            mPrimaryText.setText(titleItem.getSanitizedText());
        }
        if (mSliceStyle != null) {
            mPrimaryText.setTextSize(TypedValue.COMPLEX_UNIT_PX, mIsHeader
                    ? mSliceStyle.getHeaderTitleSize()
                    : mSliceStyle.getTitleSize());
            mPrimaryText.setTextColor(mSliceStyle.getTitleColor());
        }
        mPrimaryText.setVisibility(titleItem != null ? View.VISIBLE : View.GONE);

        addSubtitle(titleItem != null /* hasTitle */);

        mBottomDivider.setVisibility(mRowContent.hasBottomDivider() ? View.VISIBLE : View.GONE);

        SliceItem primaryAction = mRowContent.getPrimaryAction();
        if (primaryAction != null && primaryAction != mStartItem) {
            mRowAction = new SliceActionImpl(primaryAction);
            if (mRowAction.isToggle()) {
                // If primary action is a toggle, add it and we're done
                addAction(mRowAction, mTintColor, mEndContainer, false /* isStart */);
                // TODO: if start item is tappable, touch feedback should exclude it
                setViewClickable(mRootView, true);
                return;
            }
        }

        final SliceItem range = mRowContent.getRange();
        if (range != null) {
            if (mRowAction != null) {
                setViewClickable(mRootView, true);
            }
            mRangeItem = range;
            if (!skipSliderUpdate) {
                setRangeBounds();
                addRange();
            }
            return;
        }

        final SliceItem selection = mRowContent.getSelection();
        if (selection != null) {
            mSelectionItem = selection;
            addSelection(selection);
            return;
        }

        updateEndItems();
        updateActionSpinner();
    }

    private void updateEndItems() {
        if (mRowContent == null) {
            return;
        }
        mEndContainer.removeAllViews();

        // If we're here we can can show end items; check for top level actions first
        List endItems = mRowContent.getEndItems();
        if (mHeaderActions != null) {
            // Use these if we have them instead
            endItems = mHeaderActions;
        }
        // Add start item to end of row for the top row if end items are empty and presenter
        // doesn't show title items.
        if (mRowIndex == 0 && mStartItem != null && endItems.isEmpty()
                && !mRowContent.hasTitleItems()) {
            endItems.add(mStartItem);
        }

        // If we're here we might be able to show end items
        int endItemCount = 0;
        boolean firstItemIsADefaultToggle = false;
        boolean singleActionAtTheEnd = false;
        SliceItem endAction = null;
        for (int i = 0; i < endItems.size(); i++) {
            final SliceItem endItem = (endItems.get(i) instanceof SliceItem)
                    ? (SliceItem) endItems.get(i)
                    : ((SliceActionImpl) endItems.get(i)).getSliceItem();
            if (endItemCount < MAX_END_ITEMS) {
                if (addItem(endItem, mTintColor, false /* isStart */)) {
                    if (endAction == null && SliceQuery.find(endItem, FORMAT_ACTION) != null) {
                        endAction = endItem;
                    }
                    endItemCount++;
                    if (endItemCount == 1) {
                        firstItemIsADefaultToggle = !mToggles.isEmpty()
                                && SliceQuery.find(endItem.getSlice(), FORMAT_IMAGE) == null;
                        singleActionAtTheEnd = endItems.size() == 1
                                && SliceQuery.find(endItem, FORMAT_ACTION) != null;
                    }
                }
            }
        }
        mEndContainer.setVisibility(endItemCount > 0 ? VISIBLE : GONE);

        // If there is a row action and the first end item is a default toggle, or action divider
        // is set by presenter and a single action is at the end of the row, show the divider.
        mActionDivider.setVisibility(mRowAction != null && (firstItemIsADefaultToggle
                || (mRowContent.hasActionDivider() && singleActionAtTheEnd))
                ? View.VISIBLE : View.GONE);
        boolean hasStartAction = mStartItem != null
                && SliceQuery.find(mStartItem, FORMAT_ACTION) != null;
        boolean hasEndItemAction = endAction != null;

        boolean endAndRowActionTheSame = false;
        if (mRowAction != null) {
            setViewClickable(mRootView, true);
        } else if (hasEndItemAction != hasStartAction && (endItemCount == 1 || hasStartAction)) {
            // This row only has 1 action in start or end position; make whole row clickable for it
            endAndRowActionTheSame = true;
            if (!mToggles.isEmpty()) {
                mRowAction = mToggles.keySet().iterator().next();
            } else if (!mActions.isEmpty() && mActions.size() == 1) {
                mRowAction = mActions.valueAt(0).getAction();
            }
            setViewClickable(mRootView, true);
        }

        if (mRowAction != null && !endAndRowActionTheSame
                && mLoadingActions.contains(mRowAction.getSliceItem())) {
            mShowActionSpinner = true;
        }
    }

    @Override
    public void setLastUpdated(long lastUpdated) {
        super.setLastUpdated(lastUpdated);
        if (mRowContent != null) {
            addSubtitle(mRowContent.getTitleItem() != null
                    && TextUtils.isEmpty(mRowContent.getTitleItem().getSanitizedText()));
        }
    }

    private void addSubtitle(boolean hasTitle) {
        if (mRowContent == null) {
            return;
        }
        final SliceItem subtitleItem = getMode() == MODE_SMALL
                ? mRowContent.getSummaryItem()
                : mRowContent.getSubtitleItem();
        CharSequence subtitleTimeString = null;
        if (mShowLastUpdated && mLastUpdated != -1) {
            CharSequence relativeTime = getRelativeTimeString(mLastUpdated);
            if (relativeTime != null) {
                subtitleTimeString =
                        getResources().getString(R.string.abc_slice_updated, relativeTime);
            }
        }
        CharSequence subtitle = subtitleItem != null ? subtitleItem.getSanitizedText() : null;
        boolean subtitleExists = !TextUtils.isEmpty(subtitle)
                        || (subtitleItem != null && subtitleItem.hasHint(HINT_PARTIAL));
        if (subtitleExists) {
            mSecondaryText.setText(subtitle);
            if (mSliceStyle != null) {
                mSecondaryText.setTextSize(TypedValue.COMPLEX_UNIT_PX, mIsHeader
                        ? mSliceStyle.getHeaderSubtitleSize()
                        : mSliceStyle.getSubtitleSize());
                mSecondaryText.setTextColor(mSliceStyle.getSubtitleColor());
                int verticalPadding = mIsHeader
                        ? mSliceStyle.getVerticalHeaderTextPadding()
                        : mSliceStyle.getVerticalTextPadding();
                mSecondaryText.setPadding(0, verticalPadding, 0, 0);
            }
        }
        if (subtitleTimeString != null) {
            if (!TextUtils.isEmpty(subtitle)) {
                subtitleTimeString = " \u00B7 " + subtitleTimeString;
            }
            SpannableString sp = new SpannableString(subtitleTimeString);
            sp.setSpan(new StyleSpan(Typeface.ITALIC), 0, subtitleTimeString.length(), 0);
            mLastUpdatedText.setText(sp);
            if (mSliceStyle != null) {
                mLastUpdatedText.setTextSize(TypedValue.COMPLEX_UNIT_PX, mIsHeader
                        ? mSliceStyle.getHeaderSubtitleSize() : mSliceStyle.getSubtitleSize());
                mLastUpdatedText.setTextColor(mSliceStyle.getSubtitleColor());
            }
        }
        mLastUpdatedText.setVisibility(TextUtils.isEmpty(subtitleTimeString) ? GONE : VISIBLE);
        mSecondaryText.setVisibility(subtitleExists ? VISIBLE : GONE);

        // If this is non-header or something that can have 2 lines in the header (e.g. permission
        // slice) then allow 2 lines if there's only a subtitle and now timestring.
        boolean canHaveMultiLines = mRowIndex > 0 || mAllowTwoLines;
        int maxLines = canHaveMultiLines && !hasTitle && subtitleExists
                && TextUtils.isEmpty(subtitleTimeString)
                ? 2 : 1;
        mSecondaryText.setSingleLine(maxLines == 1);
        mSecondaryText.setMaxLines(maxLines);

        // TODO: Consider refactoring layout structure to avoid this
        // Need to request a layout to update the weights for these views when RV recycles them
        mSecondaryText.requestLayout();
        mLastUpdatedText.requestLayout();
    }

    private CharSequence getRelativeTimeString(long time) {
        long difference = System.currentTimeMillis() - time;
        if (difference > DateUtils.YEAR_IN_MILLIS) {
            int years = (int) (difference / DateUtils.YEAR_IN_MILLIS);
            return getResources().getQuantityString(
                    R.plurals.abc_slice_duration_years, years, years);
        } else if (difference > DateUtils.DAY_IN_MILLIS) {
            int days = (int) (difference / DateUtils.DAY_IN_MILLIS);
            return getResources().getQuantityString(R.plurals.abc_slice_duration_days, days, days);
        } else if (difference > DateUtils.MINUTE_IN_MILLIS) {
            int mins = (int) (difference / DateUtils.MINUTE_IN_MILLIS);
            return getResources().getQuantityString(R.plurals.abc_slice_duration_min, mins, mins);
        } else {
            return null;
        }
    }

    private void setRangeBounds() {
        SliceItem min = SliceQuery.findSubtype(mRangeItem, FORMAT_INT, SUBTYPE_MIN);
        int minValue = 0;
        if (min != null) {
            minValue = min.getInt();
        }
        mRangeMinValue = minValue;

        SliceItem max = SliceQuery.findSubtype(mRangeItem, FORMAT_INT, SUBTYPE_MAX);
        int maxValue = 100;  // TODO: This default shouldn't be hardcoded here.
        if (max != null) {
            maxValue = max.getInt();
        }
        mRangeMaxValue = maxValue;

        SliceItem progress = SliceQuery.findSubtype(mRangeItem, FORMAT_INT, SUBTYPE_VALUE);
        int progressValue = 0;
        if (progress != null) {
            progressValue = progress.getInt() - minValue;
        }
        mRangeValue = progressValue;
    }

    private void addRange() {
        if (mHandler == null) {
            mHandler = new Handler();
        }

        final boolean isSeekBar = FORMAT_ACTION.equals(mRangeItem.getFormat());
        final ProgressBar progressBar = isSeekBar
                ? new SeekBar(getContext())
                : new ProgressBar(getContext(), null, android.R.attr.progressBarStyleHorizontal);
        Drawable progressDrawable = DrawableCompat.wrap(progressBar.getProgressDrawable());
        if (mTintColor != -1 && progressDrawable != null) {
            DrawableCompat.setTint(progressDrawable, mTintColor);
            progressBar.setProgressDrawable(progressDrawable);
        }
        // N.B. We don't use progressBar.setMin because it doesn't work properly in backcompat
        //      and/or sliders.
        progressBar.setMax(mRangeMaxValue - mRangeMinValue);
        progressBar.setProgress(mRangeValue);
        progressBar.setVisibility(View.VISIBLE);
        addView(progressBar);
        mRangeBar = progressBar;
        if (isSeekBar) {
            SliceItem thumb = mRowContent.getInputRangeThumb();
            SeekBar seekBar = (SeekBar) mRangeBar;
            if (thumb != null && thumb.getIcon() != null) {
                Drawable d = thumb.getIcon().loadDrawable(getContext());
                if (d != null) {
                    seekBar.setThumb(d);
                }
            }
            Drawable thumbDrawable = DrawableCompat.wrap(seekBar.getThumb());
            if (mTintColor != -1 && thumbDrawable != null) {
                DrawableCompat.setTint(thumbDrawable, mTintColor);
                seekBar.setThumb(thumbDrawable);
            }
            seekBar.setOnSeekBarChangeListener(mSeekBarChangeListener);
        }
    }

    void sendSliderValue() {
        if (mRangeItem == null) {
            return;
        }

        try {
            mLastSentRangeUpdate = System.currentTimeMillis();
            mRangeItem.fireAction(getContext(),
                    new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
                            .putExtra(EXTRA_RANGE_VALUE, mRangeValue));
            if (mObserver != null) {
                EventInfo info = new EventInfo(getMode(), ACTION_TYPE_SLIDER, ROW_TYPE_SLIDER,
                        mRowIndex);
                info.state = mRangeValue;
                mObserver.onSliceAction(info, mRangeItem);
            }
        } catch (CanceledException e) {
            Log.e(TAG, "PendingIntent for slice cannot be sent", e);
        }
    }

    private void addSelection(final SliceItem selection) {
        if (mHandler == null) {
            mHandler = new Handler();
        }

        mSelectionOptionKeys = new ArrayList<String>();
        mSelectionOptionValues = new ArrayList<CharSequence>();

        final List<SliceItem> optionItems = selection.getSlice().getItems();

        for (int i = 0; i < optionItems.size(); i++) {
            final SliceItem optionItem = optionItems.get(i);
            if (!optionItem.hasHint(HINT_SELECTION_OPTION)) {
                continue;
            }

            final SliceItem optionKeyItem =
                    SliceQuery.findSubtype(optionItem, FORMAT_TEXT, SUBTYPE_SELECTION_OPTION_KEY);
            final SliceItem optionValueItem =
                    SliceQuery.findSubtype(optionItem, FORMAT_TEXT, SUBTYPE_SELECTION_OPTION_VALUE);
            if (optionKeyItem == null || optionValueItem == null) {
                continue;
            }

            mSelectionOptionKeys.add(optionKeyItem.getText().toString());
            mSelectionOptionValues.add(optionValueItem.getSanitizedText());
        }

        mSelectionSpinner = (Spinner) LayoutInflater.from(getContext()).inflate(
                R.layout.abc_slice_row_selection, this, false);

        final ArrayAdapter<CharSequence> adapter = new ArrayAdapter<>(
                getContext(), R.layout.abc_slice_row_selection_text, mSelectionOptionValues);
        adapter.setDropDownViewResource(R.layout.abc_slice_row_selection_dropdown_text);
        mSelectionSpinner.setAdapter(adapter);

        addView(mSelectionSpinner);
        // XXX: onItemSelected is called automatically.

        mSelectionSpinner.setOnItemSelectedListener(this);
    }

    /**
     * Add an action view to the container.
     */
    private void addAction(final SliceActionImpl actionContent, int color, ViewGroup container,
                           boolean isStart) {
        SliceActionView sav = new SliceActionView(getContext());
        container.addView(sav);
        if (container.getVisibility() == GONE) {
            container.setVisibility(VISIBLE);
        }

        boolean isToggle = actionContent.isToggle();
        int actionType = isToggle ? ACTION_TYPE_TOGGLE : ACTION_TYPE_BUTTON;
        int rowType = isToggle ? ROW_TYPE_TOGGLE : ROW_TYPE_LIST;
        EventInfo info = new EventInfo(getMode(), actionType, rowType, mRowIndex);
        if (isStart) {
            info.setPosition(EventInfo.POSITION_START, 0, 1);
        }
        sav.setAction(actionContent, info, mObserver, color, mLoadingListener);
        if (mLoadingActions.contains(actionContent.getSliceItem())) {
            sav.setLoading(true);
        }
        if (isToggle) {
            mToggles.put(actionContent, sav);
        } else {
            mActions.put(actionContent, sav);
        }
    }

    /**
     * Adds simple items to a container. Simple items include actions with icons, images, or
     * timestamps.
     */
    private boolean addItem(SliceItem sliceItem, int color, boolean isStart) {
        IconCompat icon = null;
        int imageMode = 0;
        SliceItem timeStamp = null;
        ViewGroup container = isStart ? mStartContainer : mEndContainer;
        if (FORMAT_SLICE.equals(sliceItem.getFormat())
                || FORMAT_ACTION.equals(sliceItem.getFormat())) {
            if (sliceItem.hasHint(HINT_SHORTCUT)) {
                addAction(new SliceActionImpl(sliceItem), color, container, isStart);
                return true;
            } else {
                if (sliceItem.getSlice().getItems().size() == 0) {
                    return false;
                }
                sliceItem = sliceItem.getSlice().getItems().get(0);
            }
        }

        if (FORMAT_IMAGE.equals(sliceItem.getFormat())) {
            icon = sliceItem.getIcon();
            imageMode = sliceItem.hasHint(HINT_NO_TINT) ? SMALL_IMAGE : ICON_IMAGE;
        } else if (FORMAT_LONG.equals(sliceItem.getFormat())) {
            timeStamp = sliceItem;
        }
        View addedView = null;
        if (icon != null) {
            boolean isIcon = imageMode == ICON_IMAGE;
            ImageView iv = new ImageView(getContext());
            iv.setImageDrawable(icon.loadDrawable(getContext()));
            if (isIcon && color != -1) {
                iv.setColorFilter(color);
            }
            container.addView(iv);
            LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) iv.getLayoutParams();
            lp.width = mImageSize;
            lp.height = mImageSize;
            iv.setLayoutParams(lp);
            int p = isIcon ? mIconSize / 2 : 0;
            iv.setPadding(p, p, p, p);
            addedView = iv;
        } else if (timeStamp != null) {
            TextView tv = new TextView(getContext());
            tv.setText(SliceViewUtil.getTimestampString(getContext(), sliceItem.getLong()));
            if (mSliceStyle != null) {
                tv.setTextSize(TypedValue.COMPLEX_UNIT_PX, mSliceStyle.getSubtitleSize());
                tv.setTextColor(mSliceStyle.getSubtitleColor());
            }
            container.addView(tv);
            addedView = tv;
        }
        return addedView != null;
    }

    private void showSeeMore() {
        final Button b = (Button) LayoutInflater.from(getContext()).inflate(
                R.layout.abc_slice_row_show_more, this, false);
        b.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                try {
                    if (mObserver != null) {
                        EventInfo info = new EventInfo(getMode(), EventInfo.ACTION_TYPE_SEE_MORE,
                                EventInfo.ROW_TYPE_LIST, mRowIndex);
                        mObserver.onSliceAction(info, mRowContent.getSliceItem());
                    }
                    mShowActionSpinner =
                            mRowContent.getSliceItem().fireActionInternal(getContext(), null);
                    if (mShowActionSpinner) {
                        if (mLoadingListener != null) {
                            mLoadingListener.onSliceActionLoading(mRowContent.getSliceItem(),
                                    mRowIndex);
                        }
                        mLoadingActions.add(mRowContent.getSliceItem());
                        b.setVisibility(GONE);
                    }
                    updateActionSpinner();
                } catch (CanceledException e) {
                    Log.e(TAG, "PendingIntent for slice cannot be sent", e);
                }
            }
        });
        if (mTintColor != -1) {
            b.setTextColor(mTintColor);
        }
        mSeeMoreView = b;
        mRootView.addView(mSeeMoreView);
        if (mLoadingActions.contains(mRowContent.getSliceItem())) {
            mShowActionSpinner = true;
            b.setVisibility(GONE);
            updateActionSpinner();
        }
    }

    void updateActionSpinner() {
        mActionSpinner.setVisibility(mShowActionSpinner ? VISIBLE : GONE);
    }

    @Override
    public void setLoadingActions(Set<SliceItem> actions) {
        if (actions == null) {
            mLoadingActions.clear();
            mShowActionSpinner = false;
        } else {
            mLoadingActions = actions;
        }
        updateEndItems();
        updateActionSpinner();
    }

    @Override
    public void onClick(View view) {
        if (mRowAction == null || mRowAction.getActionItem() == null) {
            return;
        }
        SliceActionView sav = mRowAction.isToggle()
                ? mToggles.get(mRowAction)
                : mActions.get(mRowAction);
        if (sav != null && !(view instanceof SliceActionView)) {
            // Row might have a single action item set on it, in that case we activate that item
            // and it will handle displaying any loading states / updating state for toggles
            sav.sendAction();
        } else {
            if (mRowIndex == 0) {
                // Header clicks are a little weird and SliceView needs to know about them to
                // maintain loading state; this is hooked up in SliceAdapter -- it will call
                // through to SliceView parent which has the info to perform the click.
                performClick();
            } else {
                try {
                    mShowActionSpinner =
                            mRowAction.getActionItem().fireActionInternal(getContext(), null);
                    if (mObserver != null) {
                        EventInfo info = new EventInfo(getMode(), EventInfo.ACTION_TYPE_CONTENT,
                                EventInfo.ROW_TYPE_LIST, mRowIndex);
                        mObserver.onSliceAction(info, mRowAction.getSliceItem());
                    }
                    if (mShowActionSpinner && mLoadingListener != null) {
                        mLoadingListener.onSliceActionLoading(mRowAction.getSliceItem(), mRowIndex);
                        mLoadingActions.add(mRowAction.getSliceItem());
                    }
                    updateActionSpinner();
                } catch (CanceledException e) {
                    Log.e(TAG, "PendingIntent for slice cannot be sent", e);
                }
            }
        }
    }

    @Override
    public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
        if (mSelectionItem == null
                || parent != mSelectionSpinner
                || position < 0
                || position >= mSelectionOptionKeys.size()) {
            return;
        }

        if (mObserver != null) {
            EventInfo info = new EventInfo(getMode(), ACTION_TYPE_SELECTION, ROW_TYPE_SELECTION,
                    mRowIndex);
            // TODO: Record selected item somehow?
            mObserver.onSliceAction(info, mSelectionItem);
        }

        final String optionKey = mSelectionOptionKeys.get(position);

        try {
            final boolean loading = mSelectionItem.fireActionInternal(getContext(),
                    new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
                            .putExtra(EXTRA_SELECTION, optionKey));
            if (loading) {
                mShowActionSpinner = true;
                if (mLoadingListener != null) {
                    mLoadingListener.onSliceActionLoading(mRowAction.getSliceItem(), mRowIndex);
                    mLoadingActions.add(mRowAction.getSliceItem());
                }
                updateActionSpinner();
            }
        } catch (CanceledException e) {
            Log.e(TAG, "PendingIntent for slice cannot be sent", e);
        }
    }

    @Override
    public void onNothingSelected(AdapterView<?> parent) {

    }

    private void setViewClickable(View layout, boolean isClickable) {
        layout.setOnClickListener(isClickable ? this : null);
        layout.setBackground(isClickable
                ? SliceViewUtil.getDrawable(getContext(), android.R.attr.selectableItemBackground)
                : null);
        layout.setClickable(isClickable);
    }

    @Override
    public void resetView() {
        mRowContent = null;
        mLoadingActions.clear();
        resetViewState();
    }

    private void resetViewState() {
        mRootView.setVisibility(VISIBLE);
        setLayoutDirection(View.LAYOUT_DIRECTION_INHERIT);
        setViewClickable(mRootView, false);
        setViewClickable(mContent, false);
        mStartContainer.removeAllViews();
        mEndContainer.removeAllViews();
        mEndContainer.setVisibility(GONE);
        mPrimaryText.setText(null);
        mSecondaryText.setText(null);
        mLastUpdatedText.setText(null);
        mLastUpdatedText.setVisibility(GONE);
        mToggles.clear();
        mActions.clear();
        mRowAction = null;
        mStartItem = null;
        mBottomDivider.setVisibility(GONE);
        mActionDivider.setVisibility(GONE);
        if (mSeeMoreView != null) {
            mRootView.removeView(mSeeMoreView);
            mSeeMoreView = null;
        }
        mIsRangeSliding = false;
        mRangeHasPendingUpdate = false;
        mRangeItem = null;
        mRangeMinValue = 0;
        mRangeMaxValue = 0;
        mRangeValue = 0;
        mLastSentRangeUpdate = 0;
        mHandler = null;
        if (mRangeBar != null) {
            removeView(mRangeBar);
            mRangeBar = null;
        }
        mActionSpinner.setVisibility(GONE);
        if (mSelectionSpinner != null) {
            removeView(mSelectionSpinner);
            mSelectionSpinner = null;
        }
        mSelectionItem = null;
    }

    Runnable mRangeUpdater = new Runnable() {
        @Override
        public void run() {
            sendSliderValue();
            mRangeUpdaterRunning = false;
        }
    };

    private SeekBar.OnSeekBarChangeListener mSeekBarChangeListener =
            new SeekBar.OnSeekBarChangeListener() {
                @Override
                public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
                    mRangeValue = progress + mRangeMinValue;
                    final long now = System.currentTimeMillis();
                    if (mLastSentRangeUpdate != 0 && now - mLastSentRangeUpdate > SLIDER_INTERVAL) {
                        mRangeUpdaterRunning = false;
                        mHandler.removeCallbacks(mRangeUpdater);
                        sendSliderValue();
                    } else if (!mRangeUpdaterRunning) {
                        mRangeUpdaterRunning = true;
                        mHandler.postDelayed(mRangeUpdater, SLIDER_INTERVAL);
                    }
                }

                @Override
                public void onStartTrackingTouch(SeekBar seekBar) {
                    mIsRangeSliding = true;
                }

                @Override
                public void onStopTrackingTouch(SeekBar seekBar) {
                    mIsRangeSliding = false;
                    if (mRangeUpdaterRunning || mRangeHasPendingUpdate) {
                        mRangeUpdaterRunning = false;
                        mRangeHasPendingUpdate = false;
                        mHandler.removeCallbacks(mRangeUpdater);
                        mRangeValue = seekBar.getProgress() + mRangeMinValue;
                        sendSliderValue();
                    }
                }
            };
}