blob: 640f0f0cc3f6a173fce60df13bd8fe59738af976 [file] [log] [blame]
package com.android.systemui.statusbar.policy;
import android.annotation.ColorInt;
import android.annotation.NonNull;
import android.app.Notification;
import android.app.PendingIntent;
import android.app.RemoteInput;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.InsetDrawable;
import android.graphics.drawable.RippleDrawable;
import android.os.Bundle;
import android.os.SystemClock;
import android.text.Layout;
import android.text.TextPaint;
import android.text.method.TransformationMethod;
import android.util.AttributeSet;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
import android.widget.Button;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.ContrastColorUtil;
import com.android.systemui.Dependency;
import com.android.systemui.R;
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.plugins.ActivityStarter.OnDismissAction;
import com.android.systemui.statusbar.NotificationRemoteInputManager;
import com.android.systemui.statusbar.SmartReplyController;
import com.android.systemui.statusbar.notification.NotificationUtils;
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
import com.android.systemui.statusbar.notification.collection.NotificationEntry.EditedSuggestionInfo;
import com.android.systemui.statusbar.notification.logging.NotificationLogger;
import com.android.systemui.statusbar.phone.KeyguardDismissUtil;
import java.text.BreakIterator;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.PriorityQueue;
/** View which displays smart reply and smart actions buttons in notifications. */
public class SmartReplyView extends ViewGroup {
private static final String TAG = "SmartReplyView";
private static final int MEASURE_SPEC_ANY_LENGTH =
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
private static final Comparator<View> DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR =
(v1, v2) -> ((v2.getMeasuredWidth() - v2.getPaddingLeft() - v2.getPaddingRight())
- (v1.getMeasuredWidth() - v1.getPaddingLeft() - v1.getPaddingRight()));
private static final int SQUEEZE_FAILED = -1;
private final SmartReplyConstants mConstants;
private final KeyguardDismissUtil mKeyguardDismissUtil;
private final NotificationRemoteInputManager mRemoteInputManager;
/**
* The upper bound for the height of this view in pixels. Notifications are automatically
* recreated on density or font size changes so caching this should be fine.
*/
private final int mHeightUpperLimit;
/** Spacing to be applied between views. */
private final int mSpacing;
/** Horizontal padding of smart reply buttons if all of them use only one line of text. */
private final int mSingleLineButtonPaddingHorizontal;
/** Horizontal padding of smart reply buttons if at least one of them uses two lines of text. */
private final int mDoubleLineButtonPaddingHorizontal;
/** Increase in width of a smart reply button as a result of using two lines instead of one. */
private final int mSingleToDoubleLineButtonWidthIncrease;
private final BreakIterator mBreakIterator;
private PriorityQueue<Button> mCandidateButtonQueueForSqueezing;
private View mSmartReplyContainer;
/**
* Whether the smart replies in this view were generated by the notification assistant. If not
* they're provided by the app.
*/
private boolean mSmartRepliesGeneratedByAssistant = false;
@ColorInt
private int mCurrentBackgroundColor;
@ColorInt
private final int mDefaultBackgroundColor;
@ColorInt
private final int mDefaultStrokeColor;
@ColorInt
private final int mDefaultTextColor;
@ColorInt
private final int mDefaultTextColorDarkBg;
@ColorInt
private final int mRippleColorDarkBg;
@ColorInt
private final int mRippleColor;
private final int mStrokeWidth;
private final double mMinStrokeContrast;
private ActivityStarter mActivityStarter;
public SmartReplyView(Context context, AttributeSet attrs) {
super(context, attrs);
mConstants = Dependency.get(SmartReplyConstants.class);
mKeyguardDismissUtil = Dependency.get(KeyguardDismissUtil.class);
mRemoteInputManager = Dependency.get(NotificationRemoteInputManager.class);
mHeightUpperLimit = NotificationUtils.getFontScaledHeight(mContext,
R.dimen.smart_reply_button_max_height);
mCurrentBackgroundColor = context.getColor(R.color.smart_reply_button_background);
mDefaultBackgroundColor = mCurrentBackgroundColor;
mDefaultTextColor = mContext.getColor(R.color.smart_reply_button_text);
mDefaultTextColorDarkBg = mContext.getColor(R.color.smart_reply_button_text_dark_bg);
mDefaultStrokeColor = mContext.getColor(R.color.smart_reply_button_stroke);
mRippleColor = mContext.getColor(R.color.notification_ripple_untinted_color);
mRippleColorDarkBg = Color.argb(Color.alpha(mRippleColor),
255 /* red */, 255 /* green */, 255 /* blue */);
mMinStrokeContrast = ContrastColorUtil.calculateContrast(mDefaultStrokeColor,
mDefaultBackgroundColor);
int spacing = 0;
int singleLineButtonPaddingHorizontal = 0;
int doubleLineButtonPaddingHorizontal = 0;
int strokeWidth = 0;
final TypedArray arr = context.obtainStyledAttributes(attrs, R.styleable.SmartReplyView,
0, 0);
final int length = arr.getIndexCount();
for (int i = 0; i < length; i++) {
int attr = arr.getIndex(i);
if (attr == R.styleable.SmartReplyView_spacing) {
spacing = arr.getDimensionPixelSize(i, 0);
} else if (attr == R.styleable.SmartReplyView_singleLineButtonPaddingHorizontal) {
singleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
} else if (attr == R.styleable.SmartReplyView_doubleLineButtonPaddingHorizontal) {
doubleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
} else if (attr == R.styleable.SmartReplyView_buttonStrokeWidth) {
strokeWidth = arr.getDimensionPixelSize(i, 0);
}
}
arr.recycle();
mStrokeWidth = strokeWidth;
mSpacing = spacing;
mSingleLineButtonPaddingHorizontal = singleLineButtonPaddingHorizontal;
mDoubleLineButtonPaddingHorizontal = doubleLineButtonPaddingHorizontal;
mSingleToDoubleLineButtonWidthIncrease =
2 * (doubleLineButtonPaddingHorizontal - singleLineButtonPaddingHorizontal);
mBreakIterator = BreakIterator.getLineInstance();
reallocateCandidateButtonQueueForSqueezing();
}
/**
* Returns an upper bound for the height of this view in pixels. This method is intended to be
* invoked before onMeasure, so it doesn't do any analysis on the contents of the buttons.
*/
public int getHeightUpperLimit() {
return mHeightUpperLimit;
}
private void reallocateCandidateButtonQueueForSqueezing() {
// Instead of clearing the priority queue, we re-allocate so that it would fit all buttons
// exactly. This avoids (1) wasting memory because PriorityQueue never shrinks and
// (2) growing in onMeasure.
// The constructor throws an IllegalArgument exception if initial capacity is less than 1.
mCandidateButtonQueueForSqueezing = new PriorityQueue<>(
Math.max(getChildCount(), 1), DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR);
}
/**
* Reset the smart suggestions view to allow adding new replies and actions.
*/
public void resetSmartSuggestions(View newSmartReplyContainer) {
mSmartReplyContainer = newSmartReplyContainer;
removeAllViews();
mCurrentBackgroundColor = mDefaultBackgroundColor;
}
/**
* Add buttons to the {@link SmartReplyView} - these buttons must have been preinflated using
* one of the methods in this class.
*/
public void addPreInflatedButtons(List<Button> smartSuggestionButtons) {
for (Button button : smartSuggestionButtons) {
addView(button);
}
reallocateCandidateButtonQueueForSqueezing();
}
/**
* Add smart replies to this view, using the provided {@link RemoteInput} and
* {@link PendingIntent} to respond when the user taps a smart reply. Only the replies that fit
* into the notification are shown.
*/
public List<Button> inflateRepliesFromRemoteInput(
@NonNull SmartReplies smartReplies,
SmartReplyController smartReplyController, NotificationEntry entry,
boolean delayOnClickListener) {
List<Button> buttons = new ArrayList<>();
if (smartReplies.remoteInput != null && smartReplies.pendingIntent != null) {
if (smartReplies.choices != null) {
for (int i = 0; i < smartReplies.choices.length; ++i) {
buttons.add(inflateReplyButton(
this, getContext(), i, smartReplies, smartReplyController, entry,
delayOnClickListener));
}
this.mSmartRepliesGeneratedByAssistant = smartReplies.fromAssistant;
}
}
return buttons;
}
/**
* Add smart actions to be shown next to smart replies. Only the actions that fit into the
* notification are shown.
*/
public List<Button> inflateSmartActions(@NonNull SmartActions smartActions,
SmartReplyController smartReplyController, NotificationEntry entry,
HeadsUpManager headsUpManager, boolean delayOnClickListener) {
List<Button> buttons = new ArrayList<>();
int numSmartActions = smartActions.actions.size();
for (int n = 0; n < numSmartActions; n++) {
Notification.Action action = smartActions.actions.get(n);
if (action.actionIntent != null) {
buttons.add(inflateActionButton(
this, getContext(), n, smartActions, smartReplyController, entry,
headsUpManager, delayOnClickListener));
}
}
return buttons;
}
/**
* Inflate an instance of this class.
*/
public static SmartReplyView inflate(Context context) {
return (SmartReplyView) LayoutInflater.from(context).inflate(
R.layout.smart_reply_view, null /* root */);
}
@VisibleForTesting
static Button inflateReplyButton(SmartReplyView smartReplyView, Context context,
int replyIndex, SmartReplies smartReplies, SmartReplyController smartReplyController,
NotificationEntry entry, boolean useDelayedOnClickListener) {
Button b = (Button) LayoutInflater.from(context).inflate(
R.layout.smart_reply_button, smartReplyView, false);
CharSequence choice = smartReplies.choices[replyIndex];
b.setText(choice);
OnDismissAction action = () -> {
if (smartReplyView.mConstants.getEffectiveEditChoicesBeforeSending(
smartReplies.remoteInput.getEditChoicesBeforeSending())) {
EditedSuggestionInfo editedSuggestionInfo =
new EditedSuggestionInfo(choice, replyIndex);
smartReplyView.mRemoteInputManager.activateRemoteInput(b,
new RemoteInput[] { smartReplies.remoteInput }, smartReplies.remoteInput,
smartReplies.pendingIntent, editedSuggestionInfo);
return false;
}
smartReplyController.smartReplySent(entry, replyIndex, b.getText(),
NotificationLogger.getNotificationLocation(entry).toMetricsEventEnum(),
false /* modifiedBeforeSending */);
Bundle results = new Bundle();
results.putString(smartReplies.remoteInput.getResultKey(), choice.toString());
Intent intent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
RemoteInput.addResultsToIntent(new RemoteInput[] { smartReplies.remoteInput }, intent,
results);
RemoteInput.setResultsSource(intent, RemoteInput.SOURCE_CHOICE);
entry.setHasSentReply();
try {
smartReplies.pendingIntent.send(context, 0, intent);
} catch (PendingIntent.CanceledException e) {
Log.w(TAG, "Unable to send smart reply", e);
}
// Note that as inflateReplyButton is called mSmartReplyContainer is null, but when the
// reply Button is added to the SmartReplyView mSmartReplyContainer will be set. So, it
// will not be possible for a user to trigger this on-click-listener without
// mSmartReplyContainer being set.
smartReplyView.mSmartReplyContainer.setVisibility(View.GONE);
return false; // do not defer
};
OnClickListener onClickListener = view ->
smartReplyView.mKeyguardDismissUtil.executeWhenUnlocked(action);
if (useDelayedOnClickListener) {
onClickListener = new DelayedOnClickListener(onClickListener,
smartReplyView.mConstants.getOnClickInitDelay());
}
b.setOnClickListener(onClickListener);
b.setAccessibilityDelegate(new AccessibilityDelegate() {
public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(host, info);
String label = smartReplyView.getResources().getString(
R.string.accessibility_send_smart_reply);
info.addAction(new AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, label));
}
});
SmartReplyView.setButtonColors(b, smartReplyView.mCurrentBackgroundColor,
smartReplyView.mDefaultStrokeColor, smartReplyView.mDefaultTextColor,
smartReplyView.mRippleColor, smartReplyView.mStrokeWidth);
return b;
}
@VisibleForTesting
static Button inflateActionButton(SmartReplyView smartReplyView, Context context,
int actionIndex, SmartActions smartActions,
SmartReplyController smartReplyController, NotificationEntry entry,
HeadsUpManager headsUpManager, boolean useDelayedOnClickListener) {
Notification.Action action = smartActions.actions.get(actionIndex);
Button button = (Button) LayoutInflater.from(context).inflate(
R.layout.smart_action_button, smartReplyView, false);
button.setText(action.title);
Drawable iconDrawable = action.getIcon().loadDrawable(context);
// Add the action icon to the Smart Action button.
int newIconSize = context.getResources().getDimensionPixelSize(
R.dimen.smart_action_button_icon_size);
iconDrawable.setBounds(0, 0, newIconSize, newIconSize);
button.setCompoundDrawables(iconDrawable, null, null, null);
OnClickListener onClickListener = view ->
smartReplyView.getActivityStarter().startPendingIntentDismissingKeyguard(
action.actionIntent,
() -> {
smartReplyController.smartActionClicked(
entry, actionIndex, action, smartActions.fromAssistant);
headsUpManager.removeNotification(entry.key, true);
}, entry.getRow());
if (useDelayedOnClickListener) {
onClickListener = new DelayedOnClickListener(onClickListener,
smartReplyView.mConstants.getOnClickInitDelay());
}
button.setOnClickListener(onClickListener);
// Mark this as an Action button
final LayoutParams lp = (LayoutParams) button.getLayoutParams();
lp.buttonType = SmartButtonType.ACTION;
return button;
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LayoutParams(mContext, attrs);
}
@Override
protected LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
}
@Override
protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams params) {
return new LayoutParams(params.width, params.height);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
final int targetWidth = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED
? Integer.MAX_VALUE : MeasureSpec.getSize(widthMeasureSpec);
// Mark all buttons as hidden and un-squeezed.
resetButtonsLayoutParams();
if (!mCandidateButtonQueueForSqueezing.isEmpty()) {
Log.wtf(TAG, "Single line button queue leaked between onMeasure calls");
mCandidateButtonQueueForSqueezing.clear();
}
SmartSuggestionMeasures accumulatedMeasures = new SmartSuggestionMeasures(
mPaddingLeft + mPaddingRight,
0 /* maxChildHeight */,
mSingleLineButtonPaddingHorizontal);
int displayedChildCount = 0;
// Set up a list of suggestions where actions come before replies. Note that the Buttons
// themselves have already been added to the view hierarchy in an order such that Smart
// Replies are shown before Smart Actions. The order of the list below determines which
// suggestions will be shown at all - only the first X elements are shown (where X depends
// on how much space each suggestion button needs).
List<View> smartActions = filterActionsOrReplies(SmartButtonType.ACTION);
List<View> smartReplies = filterActionsOrReplies(SmartButtonType.REPLY);
List<View> smartSuggestions = new ArrayList<>(smartActions);
smartSuggestions.addAll(smartReplies);
List<View> coveredSuggestions = new ArrayList<>();
// SmartSuggestionMeasures for all action buttons, this will be filled in when the first
// reply button is added.
SmartSuggestionMeasures actionsMeasures = null;
final int maxNumActions = mConstants.getMaxNumActions();
int numShownActions = 0;
for (View child : smartSuggestions) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (maxNumActions != -1 // -1 means 'no limit'
&& lp.buttonType == SmartButtonType.ACTION
&& numShownActions >= maxNumActions) {
// We've reached the maximum number of actions, don't add another one!
continue;
}
child.setPadding(accumulatedMeasures.mButtonPaddingHorizontal, child.getPaddingTop(),
accumulatedMeasures.mButtonPaddingHorizontal, child.getPaddingBottom());
child.measure(MEASURE_SPEC_ANY_LENGTH, heightMeasureSpec);
coveredSuggestions.add(child);
final int lineCount = ((Button) child).getLineCount();
if (lineCount < 1 || lineCount > 2) {
// If smart reply has no text, or more than two lines, then don't show it.
continue;
}
if (lineCount == 1) {
mCandidateButtonQueueForSqueezing.add((Button) child);
}
// Remember the current measurements in case the current button doesn't fit in.
SmartSuggestionMeasures originalMeasures = accumulatedMeasures.clone();
if (actionsMeasures == null && lp.buttonType == SmartButtonType.REPLY) {
// We've added all actions (we go through actions first), now add their
// measurements.
actionsMeasures = accumulatedMeasures.clone();
}
final int spacing = displayedChildCount == 0 ? 0 : mSpacing;
final int childWidth = child.getMeasuredWidth();
final int childHeight = child.getMeasuredHeight();
accumulatedMeasures.mMeasuredWidth += spacing + childWidth;
accumulatedMeasures.mMaxChildHeight =
Math.max(accumulatedMeasures.mMaxChildHeight, childHeight);
// Do we need to increase the number of lines in smart reply buttons to two?
final boolean increaseToTwoLines =
(accumulatedMeasures.mButtonPaddingHorizontal
== mSingleLineButtonPaddingHorizontal)
&& (lineCount == 2 || accumulatedMeasures.mMeasuredWidth > targetWidth);
if (increaseToTwoLines) {
accumulatedMeasures.mMeasuredWidth +=
(displayedChildCount + 1) * mSingleToDoubleLineButtonWidthIncrease;
accumulatedMeasures.mButtonPaddingHorizontal =
mDoubleLineButtonPaddingHorizontal;
}
// If the last button doesn't fit into the remaining width, try squeezing preceding
// smart reply buttons.
if (accumulatedMeasures.mMeasuredWidth > targetWidth) {
// Keep squeezing preceding and current smart reply buttons until they all fit.
while (accumulatedMeasures.mMeasuredWidth > targetWidth
&& !mCandidateButtonQueueForSqueezing.isEmpty()) {
final Button candidate = mCandidateButtonQueueForSqueezing.poll();
final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec);
if (squeezeReduction != SQUEEZE_FAILED) {
accumulatedMeasures.mMaxChildHeight =
Math.max(accumulatedMeasures.mMaxChildHeight,
candidate.getMeasuredHeight());
accumulatedMeasures.mMeasuredWidth -= squeezeReduction;
}
}
// If the current button still doesn't fit after squeezing all buttons, undo the
// last squeezing round.
if (accumulatedMeasures.mMeasuredWidth > targetWidth) {
accumulatedMeasures = originalMeasures;
// Mark all buttons from the last squeezing round as "failed to squeeze", so
// that they're re-measured without squeezing later.
markButtonsWithPendingSqueezeStatusAs(
LayoutParams.SQUEEZE_STATUS_FAILED, coveredSuggestions);
// The current button doesn't fit, keep on adding lower-priority buttons in case
// any of those fit.
continue;
}
// The current button fits, so mark all squeezed buttons as "successfully squeezed"
// to prevent them from being un-squeezed in a subsequent squeezing round.
markButtonsWithPendingSqueezeStatusAs(
LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, coveredSuggestions);
}
lp.show = true;
displayedChildCount++;
if (lp.buttonType == SmartButtonType.ACTION) {
numShownActions++;
}
}
if (mSmartRepliesGeneratedByAssistant) {
if (!gotEnoughSmartReplies(smartReplies)) {
// We don't have enough smart replies - hide all of them.
for (View smartReplyButton : smartReplies) {
final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams();
lp.show = false;
}
// Reset our measures back to when we had only added actions (before adding
// replies).
accumulatedMeasures = actionsMeasures;
}
}
// We're done squeezing buttons, so we can clear the priority queue.
mCandidateButtonQueueForSqueezing.clear();
// Finally, we need to re-measure some buttons.
remeasureButtonsIfNecessary(accumulatedMeasures.mButtonPaddingHorizontal,
accumulatedMeasures.mMaxChildHeight);
int buttonHeight = Math.max(getSuggestedMinimumHeight(), mPaddingTop
+ accumulatedMeasures.mMaxChildHeight + mPaddingBottom);
// Set the corner radius to half the button height to make the side of the buttons look like
// a semicircle.
for (View smartSuggestionButton : smartSuggestions) {
setCornerRadius((Button) smartSuggestionButton, ((float) buttonHeight) / 2);
}
setMeasuredDimension(
resolveSize(Math.max(getSuggestedMinimumWidth(),
accumulatedMeasures.mMeasuredWidth),
widthMeasureSpec),
resolveSize(buttonHeight, heightMeasureSpec));
}
/**
* Fields we keep track of inside onMeasure() to correctly measure the SmartReplyView depending
* on which suggestions are added.
*/
private static class SmartSuggestionMeasures {
int mMeasuredWidth = -1;
int mMaxChildHeight = -1;
int mButtonPaddingHorizontal = -1;
SmartSuggestionMeasures(int measuredWidth, int maxChildHeight,
int buttonPaddingHorizontal) {
this.mMeasuredWidth = measuredWidth;
this.mMaxChildHeight = maxChildHeight;
this.mButtonPaddingHorizontal = buttonPaddingHorizontal;
}
public SmartSuggestionMeasures clone() {
return new SmartSuggestionMeasures(
mMeasuredWidth, mMaxChildHeight, mButtonPaddingHorizontal);
}
}
/**
* Returns whether our notification contains at least N smart replies (or 0) where N is
* determined by {@link SmartReplyConstants}.
*/
private boolean gotEnoughSmartReplies(List<View> smartReplies) {
int numShownReplies = 0;
for (View smartReplyButton : smartReplies) {
final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams();
if (lp.show) {
numShownReplies++;
}
}
if (numShownReplies == 0
|| numShownReplies >= mConstants.getMinNumSystemGeneratedReplies()) {
// We have enough replies, yay!
return true;
}
return false;
}
private List<View> filterActionsOrReplies(SmartButtonType buttonType) {
List<View> actions = new ArrayList<>();
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) {
continue;
}
if (lp.buttonType == buttonType) {
actions.add(child);
}
}
return actions;
}
private void resetButtonsLayoutParams() {
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
lp.show = false;
lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE;
}
}
private int squeezeButton(Button button, int heightMeasureSpec) {
final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button);
if (estimatedOptimalTextWidth == SQUEEZE_FAILED) {
return SQUEEZE_FAILED;
}
return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth);
}
private int estimateOptimalSqueezedButtonTextWidth(Button button) {
// Find a line-break point in the middle of the smart reply button text.
final String rawText = button.getText().toString();
// The button sometimes has a transformation affecting text layout (e.g. all caps).
final TransformationMethod transformation = button.getTransformationMethod();
final String text = transformation == null ?
rawText : transformation.getTransformation(rawText, button).toString();
final int length = text.length();
mBreakIterator.setText(text);
if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) {
if (mBreakIterator.next() == BreakIterator.DONE) {
// Can't find a single possible line break in either direction.
return SQUEEZE_FAILED;
}
}
final TextPaint paint = button.getPaint();
final int initialPosition = mBreakIterator.current();
final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint);
final float initialRightTextWidth =
Layout.getDesiredWidth(text, initialPosition, length, paint);
float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth);
if (initialLeftTextWidth != initialRightTextWidth) {
// See if there's a better line-break point (leading to a more narrow button) in
// either left or right direction.
final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth;
final int maxSqueezeRemeasureAttempts = mConstants.getMaxSqueezeRemeasureAttempts();
for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) {
final int newPosition =
moveLeft ? mBreakIterator.previous() : mBreakIterator.next();
if (newPosition == BreakIterator.DONE) {
break;
}
final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint);
final float newRightTextWidth =
Layout.getDesiredWidth(text, newPosition, length, paint);
final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth);
if (newOptimalTextWidth < optimalTextWidth) {
optimalTextWidth = newOptimalTextWidth;
} else {
break;
}
boolean tooFar = moveLeft
? newLeftTextWidth <= newRightTextWidth
: newLeftTextWidth >= newRightTextWidth;
if (tooFar) {
break;
}
}
}
return (int) Math.ceil(optimalTextWidth);
}
/**
* Returns the combined width of the left drawable (the action icon) and the padding between the
* drawable and the button text.
*/
private int getLeftCompoundDrawableWidthWithPadding(Button button) {
Drawable[] drawables = button.getCompoundDrawables();
Drawable leftDrawable = drawables[0];
if (leftDrawable == null) return 0;
return leftDrawable.getBounds().width() + button.getCompoundDrawablePadding();
}
private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) {
int oldWidth = button.getMeasuredWidth();
if (button.getPaddingLeft() != mDoubleLineButtonPaddingHorizontal) {
// Correct for the fact that the button was laid out with single-line horizontal
// padding.
oldWidth += mSingleToDoubleLineButtonWidthIncrease;
}
// Re-measure the squeezed smart reply button.
button.setPadding(mDoubleLineButtonPaddingHorizontal, button.getPaddingTop(),
mDoubleLineButtonPaddingHorizontal, button.getPaddingBottom());
final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(
2 * mDoubleLineButtonPaddingHorizontal + textWidth
+ getLeftCompoundDrawableWidthWithPadding(button), MeasureSpec.AT_MOST);
button.measure(widthMeasureSpec, heightMeasureSpec);
final int newWidth = button.getMeasuredWidth();
final LayoutParams lp = (LayoutParams) button.getLayoutParams();
if (button.getLineCount() > 2 || newWidth >= oldWidth) {
lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED;
return SQUEEZE_FAILED;
} else {
lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING;
return oldWidth - newWidth;
}
}
private void remeasureButtonsIfNecessary(
int buttonPaddingHorizontal, int maxChildHeight) {
final int maxChildHeightMeasure =
MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY);
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (!lp.show) {
continue;
}
boolean requiresNewMeasure = false;
int newWidth = child.getMeasuredWidth();
// Re-measure reason 1: The button needs to be un-squeezed (either because it resulted
// in more than two lines or because it was unnecessary).
if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) {
requiresNewMeasure = true;
newWidth = Integer.MAX_VALUE;
}
// Re-measure reason 2: The button's horizontal padding is incorrect (because it was
// measured with the wrong number of lines).
if (child.getPaddingLeft() != buttonPaddingHorizontal) {
requiresNewMeasure = true;
if (newWidth != Integer.MAX_VALUE) {
if (buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal) {
// Change padding (2->1 line).
newWidth -= mSingleToDoubleLineButtonWidthIncrease;
} else {
// Change padding (1->2 lines).
newWidth += mSingleToDoubleLineButtonWidthIncrease;
}
}
child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
buttonPaddingHorizontal, child.getPaddingBottom());
}
// Re-measure reason 3: The button's height is less than the max height of all buttons
// (all should have the same height).
if (child.getMeasuredHeight() != maxChildHeight) {
requiresNewMeasure = true;
}
if (requiresNewMeasure) {
child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST),
maxChildHeightMeasure);
}
}
}
private void markButtonsWithPendingSqueezeStatusAs(
int squeezeStatus, List<View> coveredChildren) {
for (View child : coveredChildren) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) {
lp.squeezeStatus = squeezeStatus;
}
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
final int width = right - left;
int position = isRtl ? width - mPaddingRight : mPaddingLeft;
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (!lp.show) {
continue;
}
final int childWidth = child.getMeasuredWidth();
final int childHeight = child.getMeasuredHeight();
final int childLeft = isRtl ? position - childWidth : position;
child.layout(childLeft, 0, childLeft + childWidth, childHeight);
final int childWidthWithSpacing = childWidth + mSpacing;
if (isRtl) {
position -= childWidthWithSpacing;
} else {
position += childWidthWithSpacing;
}
}
}
@Override
protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
return lp.show && super.drawChild(canvas, child, drawingTime);
}
public void setBackgroundTintColor(int backgroundColor) {
if (backgroundColor == mCurrentBackgroundColor) {
// Same color ignoring.
return;
}
mCurrentBackgroundColor = backgroundColor;
final boolean dark = !ContrastColorUtil.isColorLight(backgroundColor);
int textColor = ContrastColorUtil.ensureTextContrast(
dark ? mDefaultTextColorDarkBg : mDefaultTextColor,
backgroundColor | 0xff000000, dark);
int strokeColor = ContrastColorUtil.ensureContrast(
mDefaultStrokeColor, backgroundColor | 0xff000000, dark, mMinStrokeContrast);
int rippleColor = dark ? mRippleColorDarkBg : mRippleColor;
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final Button child = (Button) getChildAt(i);
setButtonColors(child, backgroundColor, strokeColor, textColor, rippleColor,
mStrokeWidth);
}
}
private static void setButtonColors(Button button, int backgroundColor, int strokeColor,
int textColor, int rippleColor, int strokeWidth) {
Drawable drawable = button.getBackground();
if (drawable instanceof RippleDrawable) {
// Mutate in case other notifications are using this drawable.
drawable = drawable.mutate();
RippleDrawable ripple = (RippleDrawable) drawable;
ripple.setColor(ColorStateList.valueOf(rippleColor));
Drawable inset = ripple.getDrawable(0);
if (inset instanceof InsetDrawable) {
Drawable background = ((InsetDrawable) inset).getDrawable();
if (background instanceof GradientDrawable) {
GradientDrawable gradientDrawable = (GradientDrawable) background;
gradientDrawable.setColor(backgroundColor);
gradientDrawable.setStroke(strokeWidth, strokeColor);
}
}
button.setBackground(drawable);
}
button.setTextColor(textColor);
}
private void setCornerRadius(Button button, float radius) {
Drawable drawable = button.getBackground();
if (drawable instanceof RippleDrawable) {
// Mutate in case other notifications are using this drawable.
drawable = drawable.mutate();
RippleDrawable ripple = (RippleDrawable) drawable;
Drawable inset = ripple.getDrawable(0);
if (inset instanceof InsetDrawable) {
Drawable background = ((InsetDrawable) inset).getDrawable();
if (background instanceof GradientDrawable) {
GradientDrawable gradientDrawable = (GradientDrawable) background;
gradientDrawable.setCornerRadius(radius);
}
}
}
}
private ActivityStarter getActivityStarter() {
if (mActivityStarter == null) {
mActivityStarter = Dependency.get(ActivityStarter.class);
}
return mActivityStarter;
}
private enum SmartButtonType {
REPLY,
ACTION
}
@VisibleForTesting
static class LayoutParams extends ViewGroup.LayoutParams {
/** Button is not squeezed. */
private static final int SQUEEZE_STATUS_NONE = 0;
/**
* Button was successfully squeezed, but it might be un-squeezed later if the squeezing
* turns out to have been unnecessary (because there's still not enough space to add another
* button).
*/
private static final int SQUEEZE_STATUS_PENDING = 1;
/** Button was successfully squeezed and it won't be un-squeezed. */
private static final int SQUEEZE_STATUS_SUCCESSFUL = 2;
/**
* Button wasn't successfully squeezed. The squeezing resulted in more than two lines of
* text or it didn't reduce the button's width at all. The button will have to be
* re-measured to use only one line of text.
*/
private static final int SQUEEZE_STATUS_FAILED = 3;
private boolean show = false;
private int squeezeStatus = SQUEEZE_STATUS_NONE;
private SmartButtonType buttonType = SmartButtonType.REPLY;
private LayoutParams(Context c, AttributeSet attrs) {
super(c, attrs);
}
private LayoutParams(int width, int height) {
super(width, height);
}
@VisibleForTesting
boolean isShown() {
return show;
}
}
/**
* Data class for smart replies.
*/
public static class SmartReplies {
@NonNull
public final RemoteInput remoteInput;
@NonNull
public final PendingIntent pendingIntent;
@NonNull
public final CharSequence[] choices;
public final boolean fromAssistant;
public SmartReplies(CharSequence[] choices, RemoteInput remoteInput,
PendingIntent pendingIntent, boolean fromAssistant) {
this.choices = choices;
this.remoteInput = remoteInput;
this.pendingIntent = pendingIntent;
this.fromAssistant = fromAssistant;
}
}
/**
* Data class for smart actions.
*/
public static class SmartActions {
@NonNull
public final List<Notification.Action> actions;
public final boolean fromAssistant;
public SmartActions(List<Notification.Action> actions, boolean fromAssistant) {
this.actions = actions;
this.fromAssistant = fromAssistant;
}
}
/**
* An OnClickListener wrapper that blocks the underlying OnClickListener for a given amount of
* time.
*/
private static class DelayedOnClickListener implements OnClickListener {
private final OnClickListener mActualListener;
private final long mInitDelayMs;
private final long mInitTimeMs;
DelayedOnClickListener(OnClickListener actualOnClickListener, long initDelayMs) {
mActualListener = actualOnClickListener;
mInitDelayMs = initDelayMs;
mInitTimeMs = SystemClock.elapsedRealtime();
}
public void onClick(View v) {
if (hasFinishedInitialization()) {
mActualListener.onClick(v);
} else {
Log.i(TAG, "Accidental Smart Suggestion click registered, delay: " + mInitDelayMs);
}
}
private boolean hasFinishedInitialization() {
return SystemClock.elapsedRealtime() >= mInitTimeMs + mInitDelayMs;
}
}
}