Merge "Group chat flyout support"

This commit is contained in:
TreeHugger Robot
2020-01-21 23:47:36 +00:00
committed by Android (Google) Code Review
8 changed files with 225 additions and 109 deletions

View File

@@ -15,25 +15,50 @@
-->
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<FrameLayout
<LinearLayout
android:id="@+id/bubble_flyout_text_container"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:orientation="horizontal"
android:clipToPadding="false"
android:paddingLeft="@dimen/bubble_flyout_padding_x"
android:paddingRight="@dimen/bubble_flyout_padding_x"
android:clipChildren="false"
android:paddingStart="@dimen/bubble_flyout_padding_x"
android:paddingEnd="@dimen/bubble_flyout_padding_x"
android:paddingTop="@dimen/bubble_flyout_padding_y"
android:paddingBottom="@dimen/bubble_flyout_padding_y"
android:translationZ="@dimen/bubble_flyout_elevation">
<TextView
android:id="@+id/bubble_flyout_text"
<ImageView
android:id="@+id/bubble_flyout_avatar"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginEnd="@dimen/bubble_flyout_avatar_message_space"
android:scaleType="centerInside"
android:src="@drawable/ic_create_bubble"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@*android:string/config_bodyFontFamily"
android:maxLines="2"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Body2"/>
android:orientation="vertical">
</FrameLayout>
<TextView
android:id="@+id/bubble_flyout_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@*android:string/config_bodyFontFamilyMedium"
android:maxLines="1"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Body2"/>
<TextView
android:id="@+id/bubble_flyout_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@*android:string/config_bodyFontFamily"
android:maxLines="2"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Body2"/>
</LinearLayout>
</LinearLayout>
</merge>

View File

@@ -1097,13 +1097,15 @@
<!-- How much the bubble flyout text container is elevated. -->
<dimen name="bubble_flyout_elevation">4dp</dimen>
<!-- How much padding is around the left and right sides of the flyout text. -->
<dimen name="bubble_flyout_padding_x">16dp</dimen>
<dimen name="bubble_flyout_padding_x">12dp</dimen>
<!-- How much padding is around the top and bottom of the flyout text. -->
<dimen name="bubble_flyout_padding_y">8dp</dimen>
<dimen name="bubble_flyout_padding_y">10dp</dimen>
<!-- Size of the triangle that points from the flyout to the bubble stack. -->
<dimen name="bubble_flyout_pointer_size">6dp</dimen>
<!-- How much space to leave between the flyout (tip of the arrow) and the bubble stack. -->
<dimen name="bubble_flyout_space_from_bubble">8dp</dimen>
<!-- How much space to leave between the flyout text and the avatar displayed in the flyout. -->
<dimen name="bubble_flyout_avatar_message_space">6dp</dimen>
<!-- Padding between status bar and bubbles when displayed in expanded state -->
<dimen name="bubble_padding_top">16dp</dimen>
<!-- Size of individual bubbles. -->

View File

@@ -32,20 +32,17 @@ import android.content.pm.PackageManager;
import android.content.pm.ShortcutInfo;
import android.content.res.Resources;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.Parcelable;
import android.os.UserHandle;
import android.provider.Settings;
import android.text.TextUtils;
import android.util.Log;
import com.android.internal.annotations.VisibleForTesting;
import com.android.systemui.R;
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.util.List;
import java.util.Objects;
/**
@@ -85,6 +82,18 @@ class Bubble {
/** Whether flyout text should be suppressed, regardless of any other flags or state. */
private boolean mSuppressFlyout;
/**
* Presentational info about the flyout.
*/
public static class FlyoutMessage {
@Nullable public Drawable senderAvatar;
@Nullable public CharSequence senderName;
@Nullable public CharSequence message;
@Nullable public boolean isGroupChat;
}
private FlyoutMessage mFlyoutMessage;
public static String groupId(NotificationEntry entry) {
UserHandle user = entry.getSbn().getUser();
return user.getIdentifier() + "|" + entry.getSbn().getPackageName();
@@ -194,6 +203,7 @@ class Bubble {
mShortcutInfo = info.shortcutInfo;
mAppName = info.appName;
mFlyoutMessage = info.flyoutMessage;
mExpandedView.update(this);
mIconView.update(this, info.badgedBubbleImage, info.dotColor, info.dotPath);
@@ -307,6 +317,10 @@ class Bubble {
mSuppressFlyout = suppressFlyout;
}
FlyoutMessage getFlyoutMessage() {
return mFlyoutMessage;
}
/**
* Returns whether the notification for this bubble is a foreground service. It shows that this
* is an ongoing bubble.
@@ -368,72 +382,6 @@ class Bubble {
return intent;
}
/**
* Returns our best guess for the most relevant text summary of the latest update to this
* notification, based on its type. Returns null if there should not be an update message.
*/
CharSequence getUpdateMessage(Context context) {
final Notification underlyingNotif = mEntry.getSbn().getNotification();
final Class<? extends Notification.Style> style = underlyingNotif.getNotificationStyle();
try {
if (Notification.BigTextStyle.class.equals(style)) {
// Return the big text, it is big so probably important. If it's not there use the
// normal text.
CharSequence bigText =
underlyingNotif.extras.getCharSequence(Notification.EXTRA_BIG_TEXT);
return !TextUtils.isEmpty(bigText)
? bigText
: underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT);
} else if (Notification.MessagingStyle.class.equals(style)) {
final List<Notification.MessagingStyle.Message> messages =
Notification.MessagingStyle.Message.getMessagesFromBundleArray(
(Parcelable[]) underlyingNotif.extras.get(
Notification.EXTRA_MESSAGES));
final Notification.MessagingStyle.Message latestMessage =
Notification.MessagingStyle.findLatestIncomingMessage(messages);
if (latestMessage != null) {
final CharSequence personName = latestMessage.getSenderPerson() != null
? latestMessage.getSenderPerson().getName()
: null;
// Prepend the sender name if available since group chats also use messaging
// style.
if (!TextUtils.isEmpty(personName)) {
return context.getResources().getString(
R.string.notification_summary_message_format,
personName,
latestMessage.getText());
} else {
return latestMessage.getText();
}
}
} else if (Notification.InboxStyle.class.equals(style)) {
CharSequence[] lines =
underlyingNotif.extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES);
// Return the last line since it should be the most recent.
if (lines != null && lines.length > 0) {
return lines[lines.length - 1];
}
} else if (Notification.MediaStyle.class.equals(style)) {
// Return nothing, media updates aren't typically useful as a text update.
return null;
} else {
// Default to text extra.
return underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT);
}
} catch (ClassCastException | NullPointerException | ArrayIndexOutOfBoundsException e) {
// No use crashing, we'll just return null and the caller will assume there's no update
// message.
e.printStackTrace();
}
return null;
}
private int getDimenForPackageUser(Context context, int resId, String pkg, int userId) {
PackageManager pm = context.getPackageManager();
Resources r;

View File

@@ -32,11 +32,13 @@ import android.graphics.Path;
import android.graphics.PointF;
import android.graphics.RectF;
import android.graphics.drawable.ShapeDrawable;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
@@ -65,7 +67,9 @@ public class BubbleFlyoutView extends FrameLayout {
private final float mCornerRadius;
private final ViewGroup mFlyoutTextContainer;
private final TextView mFlyoutText;
private final ImageView mSenderAvatar;
private final TextView mSenderText;
private final TextView mMessageText;
/** Values related to the 'new' dot which we use to figure out where to collapse the flyout. */
private final float mNewDotRadius;
@@ -142,7 +146,9 @@ public class BubbleFlyoutView extends FrameLayout {
LayoutInflater.from(context).inflate(R.layout.bubble_flyout, this, true);
mFlyoutTextContainer = findViewById(R.id.bubble_flyout_text_container);
mFlyoutText = mFlyoutTextContainer.findViewById(R.id.bubble_flyout_text);
mSenderText = findViewById(R.id.bubble_flyout_name);
mSenderAvatar = findViewById(R.id.bubble_flyout_avatar);
mMessageText = mFlyoutTextContainer.findViewById(R.id.bubble_flyout_text);
final Resources res = getResources();
mFlyoutPadding = res.getDimensionPixelSize(R.dimen.bubble_flyout_padding_x);
@@ -204,9 +210,34 @@ public class BubbleFlyoutView extends FrameLayout {
/** Configures the flyout, collapsed into to dot form. */
void setupFlyoutStartingAsDot(
CharSequence updateMessage, PointF stackPos, float parentWidth,
boolean arrowPointingLeft, int dotColor, @Nullable Runnable onLayoutComplete,
@Nullable Runnable onHide, float[] dotCenter, boolean hideDot) {
Bubble.FlyoutMessage flyoutMessage,
PointF stackPos,
float parentWidth,
boolean arrowPointingLeft,
int dotColor,
@Nullable Runnable onLayoutComplete,
@Nullable Runnable onHide,
float[] dotCenter,
boolean hideDot) {
if (flyoutMessage.senderAvatar != null && flyoutMessage.isGroupChat) {
mSenderAvatar.setVisibility(VISIBLE);
mSenderAvatar.setImageDrawable(flyoutMessage.senderAvatar);
} else {
mSenderAvatar.setVisibility(GONE);
mSenderAvatar.setTranslationX(0);
mMessageText.setTranslationX(0);
mSenderText.setTranslationX(0);
}
// Name visibility
if (!TextUtils.isEmpty(flyoutMessage.senderName)) {
mSenderText.setText(flyoutMessage.senderName);
mSenderText.setVisibility(VISIBLE);
} else {
mSenderText.setVisibility(GONE);
}
mArrowPointingLeft = arrowPointingLeft;
mDotColor = dotColor;
mOnHide = onHide;
@@ -217,15 +248,15 @@ public class BubbleFlyoutView extends FrameLayout {
// Set the flyout TextView's max width in terms of percent, and then subtract out the
// padding so that the entire flyout view will be the desired width (rather than the
// TextView being the desired width + extra padding).
mFlyoutText.setMaxWidth(
mMessageText.setMaxWidth(
(int) (parentWidth * FLYOUT_MAX_WIDTH_PERCENT) - mFlyoutPadding * 2);
mFlyoutText.setText(updateMessage);
mMessageText.setText(flyoutMessage.message);
// Wait for the TextView to lay out so we know its line count.
post(() -> {
float restingTranslationY;
// Multi line flyouts get top-aligned to the bubble.
if (mFlyoutText.getLineCount() > 1) {
if (mMessageText.getLineCount() > 1) {
restingTranslationY = stackPos.y + mBubbleIconTopPadding;
} else {
// Single line flyouts are vertically centered with respect to the bubble.
@@ -289,11 +320,20 @@ public class BubbleFlyoutView extends FrameLayout {
mPercentStillFlyout = (1f - mPercentTransitionedToDot);
// Move and fade out the text.
mFlyoutText.setTranslationX(
(mArrowPointingLeft ? -getWidth() : getWidth()) * mPercentTransitionedToDot);
mFlyoutText.setAlpha(clampPercentage(
final float translationX = mPercentTransitionedToDot
* (mArrowPointingLeft ? -getWidth() : getWidth());
final float alpha = clampPercentage(
(mPercentStillFlyout - (1f - BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS))
/ BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS));
/ BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS);
mMessageText.setTranslationX(translationX);
mMessageText.setAlpha(alpha);
mSenderText.setTranslationX(translationX);
mSenderText.setAlpha(alpha);
mSenderAvatar.setTranslationX(translationX);
mSenderAvatar.setAlpha(alpha);
// Reduce the elevation towards that of the topmost bubble.
setTranslationZ(

View File

@@ -1377,9 +1377,10 @@ public class BubbleStackView extends FrameLayout {
*/
@VisibleForTesting
void animateInFlyoutForBubble(Bubble bubble) {
final CharSequence updateMessage = bubble.getUpdateMessage(getContext());
Bubble.FlyoutMessage flyoutMessage = bubble.getFlyoutMessage();
final BadgedImageView bubbleView = bubble.getIconView();
if (updateMessage == null
if (flyoutMessage == null
|| flyoutMessage.message == null
|| !bubble.showFlyout()
|| isExpanded()
|| mIsExpansionAnimating
@@ -1432,8 +1433,8 @@ public class BubbleStackView extends FrameLayout {
};
mFlyout.postDelayed(mAnimateInFlyout, 200);
};
mFlyout.setupFlyoutStartingAsDot(
updateMessage, mStackAnimationController.getStackPosition(), getWidth(),
mFlyout.setupFlyoutStartingAsDot(flyoutMessage,
mStackAnimationController.getStackPosition(), getWidth(),
mStackAnimationController.isStackOnLeftSide(),
bubble.getIconView().getDotColor() /* dotColor */,
expandFlyoutAfterDelay /* onLayoutComplete */,

View File

@@ -21,6 +21,9 @@ import static com.android.systemui.bubbles.BadgedImageView.WHITE_SCRIM_ALPHA;
import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_BUBBLES;
import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
import android.annotation.NonNull;
import android.app.Notification;
import android.app.Person;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
@@ -31,7 +34,9 @@ import android.graphics.Matrix;
import android.graphics.Path;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.os.Parcelable;
import android.service.notification.StatusBarNotification;
import android.text.TextUtils;
import android.util.Log;
import android.util.PathParser;
import android.view.LayoutInflater;
@@ -41,8 +46,10 @@ import androidx.annotation.Nullable;
import com.android.internal.graphics.ColorUtils;
import com.android.launcher3.icons.BitmapInfo;
import com.android.systemui.R;
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
import java.lang.ref.WeakReference;
import java.util.List;
/**
* Simple task to inflate views & load necessary info to display a bubble.
@@ -98,6 +105,9 @@ public class BubbleViewInfoTask extends AsyncTask<Void, Void, BubbleViewInfoTask
}
}
/**
* Info necessary to render a bubble.
*/
static class BubbleViewInfo {
BadgedImageView imageView;
BubbleExpandedView expandedView;
@@ -106,6 +116,7 @@ public class BubbleViewInfoTask extends AsyncTask<Void, Void, BubbleViewInfoTask
Bitmap badgedBubbleImage;
int dotColor;
Path dotPath;
Bubble.FlyoutMessage flyoutMessage;
@Nullable
static BubbleViewInfo populate(Context c, BubbleStackView stackView,
@@ -184,7 +195,80 @@ public class BubbleViewInfoTask extends AsyncTask<Void, Void, BubbleViewInfoTask
info.dotPath = iconPath;
info.dotColor = ColorUtils.blendARGB(badgeBitmapInfo.color,
Color.WHITE, WHITE_SCRIM_ALPHA);
// Flyout
info.flyoutMessage = extractFlyoutMessage(c, b.getEntry());
return info;
}
}
/**
* Returns our best guess for the most relevant text summary of the latest update to this
* notification, based on its type. Returns null if there should not be an update message.
*/
@NonNull
static Bubble.FlyoutMessage extractFlyoutMessage(Context context,
NotificationEntry entry) {
final Notification underlyingNotif = entry.getSbn().getNotification();
final Class<? extends Notification.Style> style = underlyingNotif.getNotificationStyle();
Bubble.FlyoutMessage bubbleMessage = new Bubble.FlyoutMessage();
bubbleMessage.isGroupChat = underlyingNotif.extras.getBoolean(
Notification.EXTRA_IS_GROUP_CONVERSATION);
try {
if (Notification.BigTextStyle.class.equals(style)) {
// Return the big text, it is big so probably important. If it's not there use the
// normal text.
CharSequence bigText =
underlyingNotif.extras.getCharSequence(Notification.EXTRA_BIG_TEXT);
bubbleMessage.message = !TextUtils.isEmpty(bigText)
? bigText
: underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT);
return bubbleMessage;
} else if (Notification.MessagingStyle.class.equals(style)) {
final List<Notification.MessagingStyle.Message> messages =
Notification.MessagingStyle.Message.getMessagesFromBundleArray(
(Parcelable[]) underlyingNotif.extras.get(
Notification.EXTRA_MESSAGES));
final Notification.MessagingStyle.Message latestMessage =
Notification.MessagingStyle.findLatestIncomingMessage(messages);
if (latestMessage != null) {
bubbleMessage.message = latestMessage.getText();
Person sender = latestMessage.getSenderPerson();
bubbleMessage.senderName = sender != null
? sender.getName()
: null;
bubbleMessage.senderAvatar = sender != null
? sender.getIcon().loadDrawable(context)
: null;
return bubbleMessage;
}
} else if (Notification.InboxStyle.class.equals(style)) {
CharSequence[] lines =
underlyingNotif.extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES);
// Return the last line since it should be the most recent.
if (lines != null && lines.length > 0) {
bubbleMessage.message = lines[lines.length - 1];
return bubbleMessage;
}
} else if (Notification.MediaStyle.class.equals(style)) {
// Return nothing, media updates aren't typically useful as a text update.
return bubbleMessage;
} else {
// Default to text extra.
bubbleMessage.message =
underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT);
return bubbleMessage;
}
} catch (ClassCastException | NullPointerException | ArrayIndexOutOfBoundsException e) {
// No use crashing, we'll just return null and the caller will assume there's no update
// message.
e.printStackTrace();
}
return bubbleMessage;
}
}

View File

@@ -45,14 +45,22 @@ import org.mockito.MockitoAnnotations;
public class BubbleFlyoutViewTest extends SysuiTestCase {
private BubbleFlyoutView mFlyout;
private TextView mFlyoutText;
private TextView mSenderName;
private float[] mDotCenter = new float[2];
private Bubble.FlyoutMessage mFlyoutMessage;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
mFlyoutMessage = new Bubble.FlyoutMessage();
mFlyoutMessage.senderName = "Josh";
mFlyoutMessage.message = "Hello";
mFlyout = new BubbleFlyoutView(getContext());
mFlyoutText = mFlyout.findViewById(R.id.bubble_flyout_text);
mSenderName = mFlyout.findViewById(R.id.bubble_flyout_name);
mDotCenter[0] = 30;
mDotCenter[1] = 30;
}
@@ -60,19 +68,21 @@ public class BubbleFlyoutViewTest extends SysuiTestCase {
@Test
public void testShowFlyout_isVisible() {
mFlyout.setupFlyoutStartingAsDot(
"Hello", new PointF(100, 100), 500, true, Color.WHITE, null, null, mDotCenter,
mFlyoutMessage,
new PointF(100, 100), 500, true, Color.WHITE, null, null, mDotCenter,
false);
mFlyout.setVisibility(View.VISIBLE);
assertEquals("Hello", mFlyoutText.getText());
assertEquals("Josh", mSenderName.getText());
assertEquals(View.VISIBLE, mFlyout.getVisibility());
}
@Test
public void testFlyoutHide_runsCallback() {
Runnable after = Mockito.mock(Runnable.class);
mFlyout.setupFlyoutStartingAsDot(
"Hello", new PointF(100, 100), 500, true, Color.WHITE, null, after, mDotCenter,
mFlyout.setupFlyoutStartingAsDot(mFlyoutMessage,
new PointF(100, 100), 500, true, Color.WHITE, null, after, mDotCenter,
false);
mFlyout.hideFlyout();
@@ -81,8 +91,8 @@ public class BubbleFlyoutViewTest extends SysuiTestCase {
@Test
public void testSetCollapsePercent() {
mFlyout.setupFlyoutStartingAsDot(
"Hello", new PointF(100, 100), 500, true, Color.WHITE, null, null, mDotCenter,
mFlyout.setupFlyoutStartingAsDot(mFlyoutMessage,
new PointF(100, 100), 500, true, Color.WHITE, null, null, mDotCenter,
false);
mFlyout.setVisibility(View.VISIBLE);

View File

@@ -45,7 +45,6 @@ public class BubbleTest extends SysuiTestCase {
private Notification mNotif;
private NotificationEntry mEntry;
private Bubble mBubble;
private Bundle mExtras;
@Before
@@ -58,7 +57,6 @@ public class BubbleTest extends SysuiTestCase {
mEntry = new NotificationEntryBuilder()
.setNotification(mNotif)
.build();
mBubble = new Bubble(mEntry);
}
@Test
@@ -66,7 +64,8 @@ public class BubbleTest extends SysuiTestCase {
final String msg = "Hello there!";
doReturn(Notification.Style.class).when(mNotif).getNotificationStyle();
mExtras.putCharSequence(Notification.EXTRA_TEXT, msg);
assertEquals(msg, mBubble.getUpdateMessage(mContext));
assertEquals(msg, BubbleViewInfoTask.extractFlyoutMessage(mContext,
mEntry).message);
}
@Test
@@ -77,7 +76,8 @@ public class BubbleTest extends SysuiTestCase {
mExtras.putCharSequence(Notification.EXTRA_BIG_TEXT, msg);
// Should be big text, not the small text.
assertEquals(msg, mBubble.getUpdateMessage(mContext));
assertEquals(msg, BubbleViewInfoTask.extractFlyoutMessage(mContext,
mEntry).message);
}
@Test
@@ -85,7 +85,8 @@ public class BubbleTest extends SysuiTestCase {
doReturn(Notification.MediaStyle.class).when(mNotif).getNotificationStyle();
// Media notifs don't get update messages.
assertNull(mBubble.getUpdateMessage(mContext));
assertNull(BubbleViewInfoTask.extractFlyoutMessage(mContext,
mEntry).message);
}
@Test
@@ -100,7 +101,8 @@ public class BubbleTest extends SysuiTestCase {
"Really? I prefer them that way."});
// Should be the last one only.
assertEquals("Really? I prefer them that way.", mBubble.getUpdateMessage(mContext));
assertEquals("Really? I prefer them that way.",
BubbleViewInfoTask.extractFlyoutMessage(mContext, mEntry).message);
}
@Test
@@ -115,6 +117,10 @@ public class BubbleTest extends SysuiTestCase {
"Oh, hello!", 0, "Mady").toBundle()});
// Should be the last one only.
assertEquals("Mady: Oh, hello!", mBubble.getUpdateMessage(mContext));
assertEquals("Oh, hello!",
BubbleViewInfoTask.extractFlyoutMessage(mContext, mEntry).message);
assertEquals("Mady",
BubbleViewInfoTask.extractFlyoutMessage(mContext,
mEntry).senderName);
}
}