diff options
30 files changed, 1751 insertions, 61 deletions
diff --git a/core/java/android/app/Notification.java b/core/java/android/app/Notification.java index ed0cfbe3d9c3..a81ad3c429ea 100644 --- a/core/java/android/app/Notification.java +++ b/core/java/android/app/Notification.java @@ -5487,6 +5487,15 @@ public class Notification implements Parcelable return mColors; } + /** + * @param isHeader If the notification is a notification header + * @return An instance of mColors after resolving the palette + */ + private Colors getColors(boolean isHeader) { + mColors.resolvePalette(mContext, mN.color, !isHeader && mN.isColorized(), mInNightMode); + return mColors; + } + private void updateBackgroundColor(RemoteViews contentView, StandardTemplateParams p) { if (isBackgroundColorized(p)) { @@ -6618,6 +6627,23 @@ public class Notification implements Parcelable return getColors(p).getContrastColor(); } + /** + * Gets the foreground color of the small icon. If the notification is colorized, this + * is the primary text color, otherwise it's the contrast-adjusted app-provided color. + * @hide + */ + public @ColorInt int getSmallIconColor(boolean isHeader) { + return getColors(/* isHeader = */ isHeader).getContrastColor(); + } + + /** + * Gets the background color of the notification. + * @hide + */ + public @ColorInt int getBackgroundColor(boolean isHeader) { + return getColors(/* isHeader = */ isHeader).getBackgroundColor(); + } + /** @return the theme's accent color for colored UI elements. */ private @ColorInt int getPrimaryAccentColor(StandardTemplateParams p) { return getColors(p).getPrimaryAccentColor(); @@ -8532,6 +8558,8 @@ public class Notification implements Parcelable boolean isImportantConversation = mConversationType == CONVERSATION_TYPE_IMPORTANT; boolean isHeaderless = !isConversationLayout && isCollapsed; + //TODO (b/217799515): ensure mConversationTitle always returns the correct + // conversationTitle, probably set mConversationTitle = conversationTitle after this CharSequence conversationTitle = !TextUtils.isEmpty(super.mBigContentTitle) ? super.mBigContentTitle : mConversationTitle; diff --git a/core/java/com/android/internal/widget/ConversationLayout.java b/core/java/com/android/internal/widget/ConversationLayout.java index 42be784d8baa..a8d0d37f78bd 100644 --- a/core/java/com/android/internal/widget/ConversationLayout.java +++ b/core/java/com/android/internal/widget/ConversationLayout.java @@ -105,6 +105,9 @@ public class ConversationLayout extends FrameLayout private int mConversationIconTopPaddingExpandedGroup; private int mConversationIconTopPadding; private int mExpandedGroupMessagePadding; + // TODO (b/217799515) Currently, mConversationText shows the conversation title, the actual + // conversation text is inside of mMessagingLinearLayout, which is misleading, we should rename + // this to mConversationTitleView private TextView mConversationText; private View mConversationIconBadge; private CachingIconView mConversationIconBadgeBg; @@ -125,6 +128,11 @@ public class ConversationLayout extends FrameLayout private int mNotificationBackgroundColor; private CharSequence mFallbackChatName; private CharSequence mFallbackGroupChatName; + //TODO (b/217799515) Currently, Notification.MessagingStyle, ConversationLayout, and + // HybridConversationNotificationView, each has their own definition of "ConversationTitle". + // What make things worse is that the term of "ConversationTitle" often confuses with + // "ConversationText". + // We need to unify them or differentiate the namings. private CharSequence mConversationTitle; private int mMessageSpacingStandard; private int mMessageSpacingGroup; @@ -160,12 +168,12 @@ public class ConversationLayout extends FrameLayout } public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs, - @AttrRes int defStyleAttr) { + @AttrRes int defStyleAttr) { super(context, attrs, defStyleAttr); } public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs, - @AttrRes int defStyleAttr, @StyleRes int defStyleRes) { + @AttrRes int defStyleAttr, @StyleRes int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } @@ -297,13 +305,17 @@ public class ConversationLayout extends FrameLayout mNameReplacement = nameReplacement; } - /** Sets this conversation as "important", adding some additional UI treatment. */ + /** + * Sets this conversation as "important", adding some additional UI treatment. + */ @RemotableViewMethod public void setIsImportantConversation(boolean isImportantConversation) { setIsImportantConversation(isImportantConversation, false); } - /** @hide **/ + /** + * @hide + **/ public void setIsImportantConversation(boolean isImportantConversation, boolean animate) { mImportantConversation = isImportantConversation; mImportanceRingView.setVisibility(isImportantConversation && mIcon.getVisibility() != GONE @@ -386,6 +398,7 @@ public class ConversationLayout extends FrameLayout /** * Set conversation data + * * @param extras Bundle contains conversation data */ @RemotableViewMethod(asyncImpl = "setDataAsync") @@ -427,6 +440,7 @@ public class ConversationLayout extends FrameLayout * RemotableViewMethod's asyncImpl of {@link #setData(Bundle)}. * This should be called on a background thread, and returns a Runnable which is then must be * called on the main thread to complete the operation and set text. + * * @param extras Bundle contains conversation data * @hide */ @@ -449,6 +463,7 @@ public class ConversationLayout extends FrameLayout /** * enable/disable precomputed text usage + * * @hide */ public void setPrecomputedTextEnabled(boolean precomputedTextEnabled) { @@ -466,7 +481,9 @@ public class ConversationLayout extends FrameLayout mImageResolver = resolver; } - /** @hide */ + /** + * @hide + */ public void setUnreadCount(int unreadCount) { mExpandButton.setNumber(unreadCount); } @@ -795,6 +812,10 @@ public class ConversationLayout extends FrameLayout mConversationTitle = conversationTitle != null ? conversationTitle.toString() : null; } + // TODO (b/217799515) getConversationTitle is not consistent with setConversationTitle + // if you call getConversationTitle() immediately after setConversationTitle(), the result + // will not correctly reflect the new change without calling updateConversationLayout, for + // example. public CharSequence getConversationTitle() { return mConversationText.getText(); } @@ -914,7 +935,7 @@ public class ConversationLayout extends FrameLayout } private void createGroupViews(List<List<MessagingMessage>> groups, - List<Person> senders, boolean showSpinner) { + List<Person> senders, boolean showSpinner) { mGroups.clear(); for (int groupIndex = 0; groupIndex < groups.size(); groupIndex++) { List<MessagingMessage> group = groups.get(groupIndex); @@ -963,8 +984,8 @@ public class ConversationLayout extends FrameLayout } private void findGroups(List<MessagingMessage> historicMessages, - List<MessagingMessage> messages, List<List<MessagingMessage>> groups, - List<Person> senders) { + List<MessagingMessage> messages, List<List<MessagingMessage>> groups, + List<Person> senders) { CharSequence currentSenderKey = null; List<MessagingMessage> currentGroup = null; int histSize = historicMessages.size(); diff --git a/core/java/com/android/internal/widget/PeopleHelper.java b/core/java/com/android/internal/widget/PeopleHelper.java index 85cedc362b99..3f5b4a0d61fe 100644 --- a/core/java/com/android/internal/widget/PeopleHelper.java +++ b/core/java/com/android/internal/widget/PeopleHelper.java @@ -22,6 +22,8 @@ import static com.android.internal.widget.MessagingPropertyAnimator.ALPHA_OUT; import android.annotation.ColorInt; import android.annotation.NonNull; import android.annotation.Nullable; +import android.app.Notification; +import android.app.Person; import android.content.Context; import android.graphics.Bitmap; import android.graphics.Canvas; @@ -222,6 +224,72 @@ public class PeopleHelper { } /** + * A class that represents a map from unique sender names in the groups to the string 1- or + * 2-character prefix strings for the names. This class uses the String value of the + * CharSequence Names as the key. + */ + public class NameToPrefixMap { + Map<String, String> mMap; + NameToPrefixMap(Map<String, String> map) { + this.mMap = map; + } + + /** + * @param name the name + * @return the prefix of the given name + */ + public String getPrefix(CharSequence name) { + return mMap.get(name.toString()); + } + } + + /** + * Same functionality as mapUniqueNamesToPrefix, but takes list-represented message groups as + * the input. This method is better when inflating MessagingGroup from the UI thread is not + * an option. + * @param groups message groups represented by lists. A message group is some consecutive + * messages (>=3) from the same sender in a conversation. + */ + public NameToPrefixMap mapUniqueNamesToPrefixWithGroupList( + List<List<Notification.MessagingStyle.Message>> groups) { + // Map of unique names to their prefix + ArrayMap<String, String> uniqueNames = new ArrayMap<>(); + // Map of single-character string prefix to the only name which uses it, or null if multiple + ArrayMap<String, CharSequence> uniqueCharacters = new ArrayMap<>(); + for (int i = 0; i < groups.size(); i++) { + List<Notification.MessagingStyle.Message> group = groups.get(i); + if (group.isEmpty()) continue; + Person sender = group.get(0).getSenderPerson(); + if (sender == null) continue; + CharSequence senderName = sender.getName(); + if (sender.getIcon() != null || TextUtils.isEmpty(senderName)) { + continue; + } + String senderNameString = senderName.toString(); + if (!uniqueNames.containsKey(senderNameString)) { + String charPrefix = findNamePrefix(senderName, null); + if (charPrefix == null) { + continue; + } + if (uniqueCharacters.containsKey(charPrefix)) { + // this character was already used, lets make it more unique. We first need to + // resolve the existing character if it exists + CharSequence existingName = uniqueCharacters.get(charPrefix); + if (existingName != null) { + uniqueNames.put(existingName.toString(), findNameSplit(existingName)); + uniqueCharacters.put(charPrefix, null); + } + uniqueNames.put(senderNameString, findNameSplit(senderName)); + } else { + uniqueNames.put(senderNameString, charPrefix); + uniqueCharacters.put(charPrefix, senderName); + } + } + } + return new NameToPrefixMap(uniqueNames); + } + + /** * Update whether the groups can hide the sender if they are first * (happens only for 1:1 conversations where the given title matches the sender's name) */ diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/ConversationNotifications.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/ConversationNotifications.kt index 6e3b15da4423..c643238b7e30 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/ConversationNotifications.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/ConversationNotifications.kt @@ -52,8 +52,8 @@ class ConversationNotificationProcessor @Inject constructor( entry: NotificationEntry, recoveredBuilder: Notification.Builder, logger: NotificationContentInflaterLogger - ) { - val messagingStyle = recoveredBuilder.style as? Notification.MessagingStyle ?: return + ): Notification.MessagingStyle? { + val messagingStyle = recoveredBuilder.style as? Notification.MessagingStyle ?: return null messagingStyle.conversationType = if (entry.ranking.channel.isImportantConversation) Notification.MessagingStyle.CONVERSATION_TYPE_IMPORTANT @@ -68,6 +68,7 @@ class ConversationNotificationProcessor @Inject constructor( } messagingStyle.unreadMessageCount = conversationNotificationManager.getUnreadCount(entry, recoveredBuilder) + return messagingStyle } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java index 73decfc326a4..639e23ae0765 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java @@ -362,8 +362,12 @@ public class PreparationCoordinator implements Coordinator { } NotifInflater.Params getInflaterParams(NotifUiAdjustment adjustment, String reason) { - return new NotifInflater.Params(adjustment.isMinimized(), reason, - adjustment.isSnoozeEnabled()); + return new NotifInflater.Params( + adjustment.isMinimized(), + reason, + adjustment.isSnoozeEnabled(), + adjustment.isChildInGroup() + ); } private void abortInflation(NotificationEntry entry, String reason) { diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifInflater.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifInflater.kt index 4483599d6857..c0b187be42f3 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifInflater.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifInflater.kt @@ -20,9 +20,9 @@ import com.android.systemui.statusbar.notification.collection.NotificationEntry import com.android.systemui.statusbar.notification.collection.render.NotifViewController /** - * Used by the [PreparationCoordinator]. When notifications are added or updated, the - * NotifInflater is asked to (re)inflated and prepare their views. This inflation occurs off the - * main thread. When the inflation is finished, NotifInflater will trigger its InflationCallback. + * Used by the [PreparationCoordinator]. When notifications are added or updated, the NotifInflater + * is asked to (re)inflated and prepare their views. This inflation occurs off the main thread. When + * the inflation is finished, NotifInflater will trigger its InflationCallback. */ interface NotifInflater { /** @@ -33,7 +33,7 @@ interface NotifInflater { fun rebindViews(entry: NotificationEntry, params: Params, callback: InflationCallback) /** - * Called to inflate the views of an entry. Views are not considered inflated until all of its + * Called to inflate the views of an entry. Views are not considered inflated until all of its * views are bound. Once all views are inflated, the InflationCallback is triggered. * * @param callback callback called after inflation finishes @@ -41,25 +41,24 @@ interface NotifInflater { fun inflateViews(entry: NotificationEntry, params: Params, callback: InflationCallback) /** - * Request to stop the inflation of an entry. For example, called when a notification is - * removed and no longer needs to be inflated. Returns whether anything may have been aborted. + * Request to stop the inflation of an entry. For example, called when a notification is removed + * and no longer needs to be inflated. Returns whether anything may have been aborted. */ fun abortInflation(entry: NotificationEntry): Boolean - /** - * Called to let the system remove the content views from the notification row. - */ + /** Called to let the system remove the content views from the notification row. */ fun releaseViews(entry: NotificationEntry) - /** - * Callback once all the views are inflated and bound for a given NotificationEntry. - */ + /** Callback once all the views are inflated and bound for a given NotificationEntry. */ interface InflationCallback { fun onInflationFinished(entry: NotificationEntry, controller: NotifViewController) } - /** - * A class holding parameters used when inflating the notification row - */ - class Params(val isLowPriority: Boolean, val reason: String, val showSnooze: Boolean) + /** A class holding parameters used when inflating the notification row */ + class Params( + val isLowPriority: Boolean, + val reason: String, + val showSnooze: Boolean, + val isChildInGroup: Boolean = false, + ) } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifUiAdjustment.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifUiAdjustment.kt index ee0b00807e27..e1d2cdc65d5a 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifUiAdjustment.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifUiAdjustment.kt @@ -20,6 +20,7 @@ import android.app.Notification import android.app.RemoteInput import android.graphics.drawable.Icon import android.text.TextUtils +import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation /** * An immutable object which contains minimal state extracted from an entry that represents state @@ -34,6 +35,7 @@ class NotifUiAdjustment internal constructor( val isSnoozeEnabled: Boolean, val isMinimized: Boolean, val needsRedaction: Boolean, + val isChildInGroup: Boolean, ) { companion object { @JvmStatic @@ -48,6 +50,11 @@ class NotifUiAdjustment internal constructor( oldAdjustment.needsRedaction != newAdjustment.needsRedaction -> true areDifferent(oldAdjustment.smartActions, newAdjustment.smartActions) -> true newAdjustment.smartReplies != oldAdjustment.smartReplies -> true + // TODO(b/217799515): Here we decide whether to re-inflate the row on every group-status + // change if we want to keep the single-line view, the following line should be: + // !oldAdjustment.isChildInGroup && newAdjustment.isChildInGroup -> true + AsyncHybridViewInflation.isEnabled && + oldAdjustment.isChildInGroup != newAdjustment.isChildInGroup -> true else -> false } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifUiAdjustmentProvider.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifUiAdjustmentProvider.kt index 058545689c01..6f44c13a3e71 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifUiAdjustmentProvider.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotifUiAdjustmentProvider.kt @@ -29,6 +29,7 @@ import com.android.systemui.statusbar.NotificationLockscreenUserManager import com.android.systemui.statusbar.notification.collection.GroupEntry import com.android.systemui.statusbar.notification.collection.NotificationEntry import com.android.systemui.statusbar.notification.collection.provider.SectionStyleProvider +import com.android.systemui.statusbar.notification.collection.render.GroupMembershipManager import com.android.systemui.util.ListenerSet import com.android.systemui.util.settings.SecureSettings import javax.inject.Inject @@ -43,7 +44,8 @@ class NotifUiAdjustmentProvider @Inject constructor( private val secureSettings: SecureSettings, private val lockscreenUserManager: NotificationLockscreenUserManager, private val sectionStyleProvider: SectionStyleProvider, - private val userTracker: UserTracker + private val userTracker: UserTracker, + private val groupMembershipManager: GroupMembershipManager, ) { private val dirtyListeners = ListenerSet<Runnable>() private var isSnoozeSettingsEnabled = false @@ -121,5 +123,6 @@ class NotifUiAdjustmentProvider @Inject constructor( isSnoozeEnabled = isSnoozeSettingsEnabled && !entry.isCanceled, isMinimized = isEntryMinimized(entry), needsRedaction = lockscreenUserManager.needsRedaction(entry), + isChildInGroup = groupMembershipManager.isChildInGroup(entry), ) } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotificationRowBinderImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotificationRowBinderImpl.java index 80ef14bb4673..cd816aea452b 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotificationRowBinderImpl.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/inflation/NotificationRowBinderImpl.java @@ -20,6 +20,7 @@ import static com.android.systemui.Flags.screenshareNotificationHiding; import static com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_CONTRACTED; import static com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_EXPANDED; import static com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_PUBLIC; +import static com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_SINGLE_LINE; import static java.util.Objects.requireNonNull; @@ -49,6 +50,7 @@ import com.android.systemui.statusbar.notification.row.RowContentBindParams; import com.android.systemui.statusbar.notification.row.RowContentBindStage; import com.android.systemui.statusbar.notification.row.RowInflaterTask; import com.android.systemui.statusbar.notification.row.dagger.ExpandableNotificationRowComponent; +import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation; import com.android.systemui.statusbar.notification.stack.NotificationListContainer; import javax.inject.Inject; @@ -127,6 +129,8 @@ public class NotificationRowBinderImpl implements NotificationRowBinder { @NonNull NotifInflater.Params params, NotificationRowContentBinder.InflationCallback callback) throws InflationException { + //TODO(b/217799515): Remove the entry parameter from getViewParentForNotification(), this + // function returns the NotificationStackScrollLayout regardless of the entry. ViewGroup parent = mListContainer.getViewParentForNotification(entry); if (entry.rowExists()) { @@ -174,6 +178,9 @@ public class NotificationRowBinderImpl implements NotificationRowBinder { params.markContentViewsFreeable(FLAG_CONTENT_VIEW_CONTRACTED); params.markContentViewsFreeable(FLAG_CONTENT_VIEW_EXPANDED); params.markContentViewsFreeable(FLAG_CONTENT_VIEW_PUBLIC); + if (AsyncHybridViewInflation.isEnabled()) { + params.markContentViewsFreeable(FLAG_CONTENT_VIEW_SINGLE_LINE); + } mRowContentBindStage.requestRebind(entry, null); } @@ -254,6 +261,16 @@ public class NotificationRowBinderImpl implements NotificationRowBinder { params.markContentViewsFreeable(FLAG_CONTENT_VIEW_PUBLIC); } + if (AsyncHybridViewInflation.isEnabled()) { + if (inflaterParams.isChildInGroup()) { + params.requireContentViews(FLAG_CONTENT_VIEW_SINGLE_LINE); + } else { + // TODO(b/217799515): here we decide whether to free the single-line view + // when the group status changes + params.markContentViewsFreeable(FLAG_CONTENT_VIEW_SINGLE_LINE); + } + } + params.rebindAllContentViews(); mLogger.logRequestingRebind(entry, inflaterParams); mRowContentBindStage.requestRebind(entry, en -> { diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDiffer.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDiffer.kt index 61e6f65b2bc2..8021d8f58ccc 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDiffer.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/render/ShadeViewDiffer.kt @@ -127,6 +127,9 @@ class ShadeViewDiffer( } } + /** + * Attach the Child Nodes to the parentNode using the structure from specMap + */ private fun attachChildren( parentNode: ShadeNode, specMap: Map<NodeController, NodeSpec> diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/BindStage.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/BindStage.java index d626c18e46f5..8ae324fa4ef8 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/BindStage.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/BindStage.java @@ -44,6 +44,7 @@ public abstract class BindStage<Params> extends BindRequester { /** * Execute the stage asynchronously. * + * @param entry the NotificationEntry to bind * @param row notification top-level view to bind views to * @param callback callback after stage finishes */ diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridConversationNotificationView.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridConversationNotificationView.java index 43d99a0e03f2..6bc2b2f9e250 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridConversationNotificationView.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridConversationNotificationView.java @@ -16,19 +16,27 @@ package com.android.systemui.statusbar.notification.row; +import android.annotation.NonNull; import android.annotation.Nullable; import android.content.Context; +import android.content.res.ColorStateList; import android.graphics.drawable.Icon; import android.text.TextUtils; import android.util.AttributeSet; import android.view.View; +import android.view.ViewStub; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.TextView; +import com.android.internal.annotations.VisibleForTesting; import com.android.internal.widget.ConversationLayout; import com.android.systemui.res.R; import com.android.systemui.statusbar.notification.NotificationFadeAware; +import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation; +import com.android.systemui.statusbar.notification.row.ui.viewmodel.ConversationAvatar; +import com.android.systemui.statusbar.notification.row.ui.viewmodel.FacePile; +import com.android.systemui.statusbar.notification.row.ui.viewmodel.SingleIcon; /** * A hybrid view which may contain information about one ore more conversations. @@ -37,6 +45,7 @@ public class HybridConversationNotificationView extends HybridNotificationView { private ImageView mConversationIconView; private TextView mConversationSenderName; + private ViewStub mConversationFacePileStub; private View mConversationFacePile; private int mSingleAvatarSize; private int mFacePileSize; @@ -65,7 +74,16 @@ public class HybridConversationNotificationView extends HybridNotificationView { protected void onFinishInflate() { super.onFinishInflate(); mConversationIconView = requireViewById(com.android.internal.R.id.conversation_icon); - mConversationFacePile = requireViewById(com.android.internal.R.id.conversation_face_pile); + if (AsyncHybridViewInflation.isEnabled()) { + mConversationFacePileStub = + requireViewById(com.android.internal.R.id.conversation_face_pile); + } else { + // TODO(b/217799515): This usage is vague because mConversationFacePile represents both + // View and ViewStub at different stages of View inflation, should be removed when + // AsyncHybridViewInflation flag is removed + mConversationFacePile = + requireViewById(com.android.internal.R.id.conversation_face_pile); + } mConversationSenderName = requireViewById(R.id.conversation_notification_sender); applyTextColor(mConversationSenderName, mSecondaryTextColor); mFacePileSize = getResources() @@ -85,7 +103,8 @@ public class HybridConversationNotificationView extends HybridNotificationView { @Override public void bind(@Nullable CharSequence title, @Nullable CharSequence text, - @Nullable View contentView) { + @Nullable View contentView) { + AsyncHybridViewInflation.assertInLegacyMode(); if (!(contentView instanceof ConversationLayout)) { super.bind(title, text, contentView); return; @@ -137,6 +156,77 @@ public class HybridConversationNotificationView extends HybridNotificationView { super.bind(conversationTitle, conversationText, conversationLayout); } + /** + * Set the avatar using ConversationAvatar from SingleLineViewModel + * + * @param conversationAvatar the icon needed for a single-line conversation view, it should be + * either an instance of SingleIcon or FacePile + */ + public void setAvatar(@NonNull ConversationAvatar conversationAvatar) { + if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) return; + if (conversationAvatar instanceof SingleIcon) { + SingleIcon avatar = (SingleIcon) conversationAvatar; + if (mConversationFacePile != null) mConversationFacePile.setVisibility(GONE); + mConversationIconView.setVisibility(VISIBLE); + mConversationIconView.setImageDrawable(avatar.getIconDrawable()); + setSize(mConversationIconView, mSingleAvatarSize); + return; + } + + // If conversationAvatar is not a SingleIcon, it should be a FacePile. + // Bind the face pile with it. + FacePile facePileModel = (FacePile) conversationAvatar; + mConversationIconView.setVisibility(GONE); + // Inflate mConversationFacePile from ViewStub + if (mConversationFacePile == null) { + mConversationFacePile = mConversationFacePileStub.inflate(); + } + mConversationFacePile.setVisibility(VISIBLE); + + ImageView facePileBottomBg = mConversationFacePile.requireViewById( + com.android.internal.R.id.conversation_face_pile_bottom_background); + ImageView facePileBottom = mConversationFacePile.requireViewById( + com.android.internal.R.id.conversation_face_pile_bottom); + ImageView facePileTop = mConversationFacePile.requireViewById( + com.android.internal.R.id.conversation_face_pile_top); + + int bottomBackgroundColor = facePileModel.getBottomBackgroundColor(); + facePileBottomBg.setImageTintList(ColorStateList.valueOf(bottomBackgroundColor)); + + facePileBottom.setImageDrawable(facePileModel.getBottomIconDrawable()); + facePileTop.setImageDrawable(facePileModel.getTopIconDrawable()); + + setSize(mConversationFacePile, mFacePileSize); + setSize(facePileBottom, mFacePileAvatarSize); + setSize(facePileTop, mFacePileAvatarSize); + setSize(facePileBottomBg, mFacePileAvatarSize + 2 * mFacePileProtectionWidth); + + mTransformationHelper.addViewTransformingToSimilar(facePileTop); + mTransformationHelper.addViewTransformingToSimilar(facePileBottom); + mTransformationHelper.addViewTransformingToSimilar(facePileBottomBg); + + } + + /** + * bind the text views + */ + public void setText( + CharSequence titleText, + CharSequence contentText, + CharSequence conversationSenderName + ) { + if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) return; + if (conversationSenderName == null) { + mConversationSenderName.setVisibility(GONE); + } else { + mConversationSenderName.setVisibility(VISIBLE); + mConversationSenderName.setText(conversationSenderName); + } + // TODO (b/217799515): super.bind() doesn't use contentView, remove the contentView + // argument when the flag is removed + super.bind(/* title = */ titleText, /* text = */ contentText, /* contentView = */ null); + } + private static void setSize(View view, int size) { FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) view.getLayoutParams(); lp.width = size; @@ -153,4 +243,9 @@ public class HybridConversationNotificationView extends HybridNotificationView { super.setNotificationFaded(faded); NotificationFadeAware.setLayerTypeForFaded(mConversationFacePile, faded); } + + @VisibleForTesting + TextView getConversationSenderNameView() { + return mConversationSenderName; + } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridGroupManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridGroupManager.java index ddd9bddc7375..09c034978977 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridGroupManager.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridGroupManager.java @@ -32,6 +32,7 @@ import android.widget.TextView; import com.android.internal.widget.ConversationLayout; import com.android.systemui.res.R; +import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation; /** * A class managing hybrid groups that include {@link HybridNotificationView} and the notification @@ -41,6 +42,8 @@ public class HybridGroupManager { private final Context mContext; + private static final String TAG = "HybridGroupManager"; + private float mOverflowNumberSize; private int mOverflowNumberPadding; @@ -93,21 +96,34 @@ public class HybridGroupManager { public HybridNotificationView bindFromNotification(HybridNotificationView reusableView, View contentView, StatusBarNotification notification, ViewGroup parent) { + AsyncHybridViewInflation.assertInLegacyMode(); boolean isNewView = false; if (reusableView == null) { Trace.beginSection("HybridGroupManager#bindFromNotification"); reusableView = inflateHybridView(contentView, parent); isNewView = true; } - CharSequence titleText = resolveTitle(notification.getNotification()); - CharSequence contentText = resolveText(notification.getNotification()); - reusableView.bind(titleText, contentText, contentView); + + updateReusableView(reusableView, notification, contentView); if (isNewView) { Trace.endSection(); } return reusableView; } + /** + * Update the HybridNotificationView (single-line view)'s appearance + */ + public void updateReusableView(HybridNotificationView reusableView, + StatusBarNotification notification, View contentView) { + AsyncHybridViewInflation.assertInLegacyMode(); + final CharSequence titleText = resolveTitle(notification.getNotification()); + final CharSequence contentText = resolveText(notification.getNotification()); + if (reusableView != null) { + reusableView.bind(titleText, contentText, contentView); + } + } + @Nullable public static CharSequence resolveText(Notification notification) { CharSequence contentText = notification.extras.getCharSequence(Notification.EXTRA_TEXT); diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentInflater.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentInflater.java index f186e665f773..913d5f6d3848 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentInflater.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentInflater.java @@ -20,6 +20,7 @@ import static com.android.internal.annotations.VisibleForTesting.Visibility.PACK import static com.android.systemui.statusbar.notification.row.NotificationContentView.VISIBLE_TYPE_CONTRACTED; import static com.android.systemui.statusbar.notification.row.NotificationContentView.VISIBLE_TYPE_EXPANDED; import static com.android.systemui.statusbar.notification.row.NotificationContentView.VISIBLE_TYPE_HEADSUP; +import static com.android.systemui.statusbar.notification.row.NotificationContentView.VISIBLE_TYPE_SINGLELINE; import android.annotation.NonNull; import android.annotation.Nullable; @@ -41,15 +42,19 @@ import android.widget.RemoteViews; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.widget.ImageMessageConsumer; -import com.android.systemui.res.R; import com.android.systemui.dagger.SysUISingleton; import com.android.systemui.dagger.qualifiers.Background; import com.android.systemui.media.controls.util.MediaFeatureFlag; +import com.android.systemui.res.R; import com.android.systemui.statusbar.InflationTask; import com.android.systemui.statusbar.NotificationRemoteInputManager; import com.android.systemui.statusbar.notification.ConversationNotificationProcessor; import com.android.systemui.statusbar.notification.InflationException; import com.android.systemui.statusbar.notification.collection.NotificationEntry; +import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation; +import com.android.systemui.statusbar.notification.row.ui.viewbinder.SingleLineConversationViewBinder; +import com.android.systemui.statusbar.notification.row.ui.viewbinder.SingleLineViewBinder; +import com.android.systemui.statusbar.notification.row.ui.viewmodel.SingleLineViewModel; import com.android.systemui.statusbar.notification.row.wrapper.NotificationViewWrapper; import com.android.systemui.statusbar.phone.CentralSurfaces; import com.android.systemui.statusbar.policy.InflatedSmartReplyState; @@ -135,7 +140,7 @@ public class NotificationContentInflater implements NotificationRowContentBinder AsyncInflationTask task = new AsyncInflationTask( mBgExecutor, mInflateSynchronously, - contentToBind, + /* reInflateFlags = */ contentToBind, mRemoteViewCache, entry, mConversationProcessor, @@ -145,7 +150,7 @@ public class NotificationContentInflater implements NotificationRowContentBinder bindParams.usesIncreasedHeadsUpHeight, callback, mRemoteInputManager.getRemoteViewsOnClickHandler(), - mIsMediaInQS, + /* isMediaFlagEnabled = */ mIsMediaInQS, mSmartReplyStateInflater, mNotifLayoutInflaterFactoryProvider, mLogger); @@ -178,6 +183,29 @@ public class NotificationContentInflater implements NotificationRowContentBinder result = inflateSmartReplyViews(result, reInflateFlags, entry, row.getContext(), packageContext, row.getExistingSmartReplyState(), smartRepliesInflater, mLogger); + if (AsyncHybridViewInflation.isEnabled()) { + boolean isConversation = entry.getRanking().isConversation(); + Notification.MessagingStyle messagingStyle = null; + if (isConversation) { + messagingStyle = mConversationProcessor + .processNotification(entry, builder, mLogger); + } + result.mInflatedSingleLineViewModel = SingleLineViewInflater + .inflateSingleLineViewModel( + entry.getSbn().getNotification(), + messagingStyle, + builder, + row.getContext() + ); + result.mInflatedSingleLineViewHolder = + SingleLineViewInflater.inflateSingleLineViewHolder( + isConversation, + reInflateFlags, + entry, + row.getContext(), + mLogger + ); + } apply( mBgExecutor, @@ -255,6 +283,15 @@ public class NotificationContentInflater implements NotificationRowContentBinder mRemoteViewCache.removeCachedView(entry, FLAG_CONTENT_VIEW_PUBLIC); }); break; + case FLAG_CONTENT_VIEW_SINGLE_LINE: { + if (AsyncHybridViewInflation.isEnabled()) { + row.getPrivateLayout().performWhenContentInactive( + VISIBLE_TYPE_SINGLELINE, + () -> row.getPrivateLayout().setSingleLineView(null) + ); + } + break; + } default: break; } @@ -282,6 +319,10 @@ public class NotificationContentInflater implements NotificationRowContentBinder if ((contentViews & FLAG_CONTENT_VIEW_PUBLIC) != 0) { row.getPublicLayout().removeContentInactiveRunnable(VISIBLE_TYPE_CONTRACTED); } + if (AsyncHybridViewInflation.isEnabled() + && (contentViews & FLAG_CONTENT_VIEW_SINGLE_LINE) != 0) { + row.getPrivateLayout().removeContentInactiveRunnable(VISIBLE_TYPE_SINGLELINE); + } } private static InflationProgress inflateSmartReplyViews( @@ -772,6 +813,25 @@ public class NotificationContentInflater implements NotificationRowContentBinder } setRepliesAndActions = true; } + + if (AsyncHybridViewInflation.isEnabled() + && (reInflateFlags & FLAG_CONTENT_VIEW_SINGLE_LINE) != 0) { + HybridNotificationView viewHolder = result.mInflatedSingleLineViewHolder; + SingleLineViewModel viewModel = result.mInflatedSingleLineViewModel; + if (viewHolder != null && viewModel != null) { + if (viewModel.isConversation()) { + SingleLineConversationViewBinder.bind( + result.mInflatedSingleLineViewModel, + result.mInflatedSingleLineViewHolder + ); + } else { + SingleLineViewBinder.bind(result.mInflatedSingleLineViewModel, + result.mInflatedSingleLineViewHolder); + } + privateLayout.setSingleLineView(result.mInflatedSingleLineViewHolder); + } + } + if (setRepliesAndActions) { privateLayout.setInflatedSmartReplyState(result.inflatedSmartReplyState); } @@ -941,19 +1001,23 @@ public class NotificationContentInflater implements NotificationRowContentBinder // For all of our templates, we want it to be RTL packageContext = new RtlEnabledContext(packageContext); } - if (mEntry.getRanking().isConversation()) { - mConversationProcessor.processNotification(mEntry, recoveredBuilder, mLogger); + boolean isConversation = mEntry.getRanking().isConversation(); + Notification.MessagingStyle messagingStyle = null; + if (isConversation) { + messagingStyle = mConversationProcessor.processNotification( + mEntry, recoveredBuilder, mLogger); } InflationProgress inflationProgress = createRemoteViews(mReInflateFlags, recoveredBuilder, mIsLowPriority, mUsesIncreasedHeight, mUsesIncreasedHeadsUpHeight, packageContext, mRow, mNotifLayoutInflaterFactoryProvider, mLogger); + mLogger.logAsyncTaskProgress(mEntry, "getting existing smart reply state (on wrong thread!)"); InflatedSmartReplyState previousSmartReplyState = mRow.getExistingSmartReplyState(); mLogger.logAsyncTaskProgress(mEntry, "inflating smart reply views"); InflationProgress result = inflateSmartReplyViews( - inflationProgress, + /* result = */ inflationProgress, mReInflateFlags, mEntry, mContext, @@ -962,6 +1026,27 @@ public class NotificationContentInflater implements NotificationRowContentBinder mSmartRepliesInflater, mLogger); + if (AsyncHybridViewInflation.isEnabled()) { + // Inflate the single-line content view's ViewModel and ViewHolder from the + // background thread, the ViewHolder needs to be bind with ViewModel later from + // the main thread. + result.mInflatedSingleLineViewModel = SingleLineViewInflater + .inflateSingleLineViewModel( + mEntry.getSbn().getNotification(), + messagingStyle, + recoveredBuilder, + mContext + ); + result.mInflatedSingleLineViewHolder = + SingleLineViewInflater.inflateSingleLineViewHolder( + isConversation, + mReInflateFlags, + mEntry, + mContext, + mLogger + ); + } + mLogger.logAsyncTaskProgress(mEntry, "getting row image resolver (on wrong thread!)"); final NotificationInlineImageResolver imageResolver = mRow.getImageResolver(); @@ -1078,6 +1163,11 @@ public class NotificationContentInflater implements NotificationRowContentBinder private InflatedSmartReplyState inflatedSmartReplyState; private InflatedSmartReplyViewHolder expandedInflatedSmartReplies; private InflatedSmartReplyViewHolder headsUpInflatedSmartReplies; + + // ViewModel for SingleLineView, holds the UI State + SingleLineViewModel mInflatedSingleLineViewModel; + // Inflated SingleLineViewHolder, SingleLineView that lacks the UI State + HybridNotificationView mInflatedSingleLineViewHolder; } @VisibleForTesting diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentInflaterLogger.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentInflaterLogger.kt index 4f5455dc455f..ee9462c60674 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentInflaterLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentInflaterLogger.kt @@ -26,6 +26,7 @@ import com.android.systemui.statusbar.notification.row.NotificationRowContentBin import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_EXPANDED import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_HEADS_UP import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_PUBLIC +import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_SINGLE_LINE import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.InflationFlag import javax.inject.Inject @@ -99,6 +100,26 @@ constructor(@NotifInflationLog private val buffer: LogBuffer) { ) } + fun logInflateSingleLine( + entry: NotificationEntry, + @InflationFlag inflationFlags: Int, + isConversation: Boolean + ) { + buffer.log( + TAG, + LogLevel.DEBUG, + { + str1 = entry.logKey + int1 = inflationFlags + bool1 = isConversation + }, + { + "inflateSingleLineView, inflationFlags: ${flagToString(int1)} for $str1, " + + "isConversation: $bool1" + } + ) + } + companion object { fun flagToString(@InflationFlag flag: Int): String { if (flag == 0) { @@ -121,6 +142,9 @@ constructor(@NotifInflationLog private val buffer: LogBuffer) { if (flag and FLAG_CONTENT_VIEW_PUBLIC != 0) { l.add("PUBLIC") } + if (flag and FLAG_CONTENT_VIEW_SINGLE_LINE != 0) { + l.add("SINGLE_LINE") + } return l.joinToString("|") } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentView.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentView.java index a1718b9fbb02..402ea51bebb6 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentView.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationContentView.java @@ -57,6 +57,7 @@ import com.android.systemui.statusbar.notification.NotificationUtils; import com.android.systemui.statusbar.notification.collection.NotificationEntry; import com.android.systemui.statusbar.notification.collection.render.GroupMembershipManager; import com.android.systemui.statusbar.notification.people.PeopleNotificationIdentifier; +import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation; import com.android.systemui.statusbar.notification.row.wrapper.NotificationCustomViewWrapper; import com.android.systemui.statusbar.notification.row.wrapper.NotificationViewWrapper; import com.android.systemui.statusbar.policy.InflatedSmartReplyState; @@ -86,7 +87,7 @@ public class NotificationContentView extends FrameLayout implements Notification public static final int VISIBLE_TYPE_CONTRACTED = 0; public static final int VISIBLE_TYPE_EXPANDED = 1; public static final int VISIBLE_TYPE_HEADSUP = 2; - private static final int VISIBLE_TYPE_SINGLELINE = 3; + public static final int VISIBLE_TYPE_SINGLELINE = 3; /** * Used when there is no content on the view such as when we're a public layout but don't * need to show. @@ -98,6 +99,7 @@ public class NotificationContentView extends FrameLayout implements Notification private final Rect mClipBounds = new Rect(); private int mMinContractedHeight; + private int mMinSingleLineHeight; private View mContractedChild; private View mExpandedChild; private View mHeadsUpChild; @@ -234,6 +236,11 @@ public class NotificationContentView extends FrameLayout implements Notification public void reinflate() { mMinContractedHeight = getResources().getDimensionPixelSize( R.dimen.min_notification_layout_height); + if (AsyncHybridViewInflation.isEnabled()) { + //TODO: set the height with a more reasonable min single-line height + mMinSingleLineHeight = getResources().getDimensionPixelSize( + R.dimen.conversation_single_line_face_pile_size); + } } public void setHeights(int smallHeight, int headsUpMaxHeight, int maxHeight) { @@ -540,6 +547,28 @@ public class NotificationContentView extends FrameLayout implements Notification updateShownWrapper(mVisibleType); } + /** + * Sets the single-line view. Child may be null to remove the view. + * @param child single-line content view to set + */ + public void setSingleLineView(@Nullable HybridNotificationView child) { + if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) return; + if (mSingleLineView != null) { + mOnContentViewInactiveListeners.remove(mSingleLineView); + mSingleLineView.animate().cancel(); + removeView(mSingleLineView); + } + if (child == null) { + mSingleLineView = null; + if (mTransformationStartVisibleType == VISIBLE_TYPE_SINGLELINE) { + mTransformationStartVisibleType = VISIBLE_TYPE_NONE; + } + return; + } + addView(child); + mSingleLineView = child; + } + @Override public void onViewAdded(View child) { super.onViewAdded(child); @@ -809,7 +838,17 @@ public class NotificationContentView extends FrameLayout implements Notification return mContractedChild != null ? getViewHeight(VISIBLE_TYPE_CONTRACTED) : mMinContractedHeight; } else { - return mSingleLineView.getHeight(); + if (AsyncHybridViewInflation.isEnabled()) { + if (mSingleLineView != null) { + return getViewHeight(VISIBLE_TYPE_SINGLELINE); + } else { + Log.wtf(TAG, "getMinHeight: mSingleLineView == null"); + return mMinSingleLineHeight; + } + } else { + AsyncHybridViewInflation.assertInLegacyMode(); + return mSingleLineView.getHeight(); + } } } @@ -1264,19 +1303,30 @@ public class NotificationContentView extends FrameLayout implements Notification } private void updateSingleLineView() { - if (mIsChildInGroup) { + try { Trace.beginSection("NotifContentView#updateSingleLineView"); - boolean isNewView = mSingleLineView == null; - mSingleLineView = mHybridGroupManager.bindFromNotification( - mSingleLineView, mContractedChild, mNotificationEntry.getSbn(), this); - if (isNewView) { - updateViewVisibility(mVisibleType, VISIBLE_TYPE_SINGLELINE, - mSingleLineView, mSingleLineView); + if (AsyncHybridViewInflation.isEnabled()) { + return; + } + AsyncHybridViewInflation.assertInLegacyMode(); + if (mIsChildInGroup) { + boolean isNewView = mSingleLineView == null; + mSingleLineView = mHybridGroupManager.bindFromNotification( + /* reusableView = */ mSingleLineView, + /* contentView = */ mContractedChild, + /* notification = */ mNotificationEntry.getSbn(), + /* parent = */ this + ); + if (isNewView && mSingleLineView != null) { + updateViewVisibility(mVisibleType, VISIBLE_TYPE_SINGLELINE, + mSingleLineView, mSingleLineView); + } + } else if (mSingleLineView != null) { + removeView(mSingleLineView); + mSingleLineView = null; } + } finally { Trace.endSection(); - } else if (mSingleLineView != null) { - removeView(mSingleLineView); - mSingleLineView = null; } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinder.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinder.java index d7b7aa210257..736140c44dfd 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinder.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/NotificationRowContentBinder.java @@ -80,6 +80,7 @@ public interface NotificationRowContentBinder { FLAG_CONTENT_VIEW_EXPANDED, FLAG_CONTENT_VIEW_HEADS_UP, FLAG_CONTENT_VIEW_PUBLIC, + FLAG_CONTENT_VIEW_SINGLE_LINE, FLAG_CONTENT_VIEW_ALL}) @interface InflationFlag {} /** @@ -102,7 +103,12 @@ public interface NotificationRowContentBinder { */ int FLAG_CONTENT_VIEW_PUBLIC = 1 << 3; - int FLAG_CONTENT_VIEW_ALL = (1 << 4) - 1; + /** + * The single line notification view. Show when the notification is shown as a child in group. + */ + int FLAG_CONTENT_VIEW_SINGLE_LINE = 1 << 4; + + int FLAG_CONTENT_VIEW_ALL = (1 << 5) - 1; /** * Parameters for content view binding diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindParams.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindParams.java index a52f638e7c26..1494c275d061 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindParams.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindParams.java @@ -102,9 +102,9 @@ public final class RowContentBindParams { * @see InflationFlag */ public void markContentViewsFreeable(@InflationFlag int contentViews) { - @InflationFlag int existingContentViews = contentViews &= mContentViews; + @InflationFlag int existingFreeableContentViews = contentViews &= mContentViews; mContentViews &= ~contentViews; - mDirtyContentViews |= existingContentViews; + mDirtyContentViews |= existingFreeableContentViews; } public @InflationFlag int getContentViews() { diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindStage.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindStage.java index b70da00ad517..f4f8374d0a9f 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindStage.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/RowContentBindStage.java @@ -63,7 +63,10 @@ public class RowContentBindStage extends BindStage<RowContentBindParams> { @InflationFlag int inflationFlags = params.getContentViews(); @InflationFlag int invalidatedFlags = params.getDirtyContentViews(); + // Rebind the content views which are needed now, and the corresponding old views are + // invalidated @InflationFlag int contentToBind = invalidatedFlags & inflationFlags; + // Unbind the content views that are not needed @InflationFlag int contentToUnbind = inflationFlags ^ FLAG_CONTENT_VIEW_ALL; // Bind/unbind with parameters diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/SingleLineViewInflater.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/SingleLineViewInflater.kt new file mode 100644 index 000000000000..d6118a0b3865 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/SingleLineViewInflater.kt @@ -0,0 +1,390 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.statusbar.notification.row + +import android.app.Notification +import android.app.Notification.MessagingStyle +import android.app.Person +import android.content.Context +import android.graphics.drawable.Icon +import android.util.Log +import android.view.LayoutInflater +import com.android.app.tracing.traceSection +import com.android.internal.R +import com.android.internal.widget.MessagingMessage +import com.android.internal.widget.PeopleHelper +import com.android.systemui.statusbar.notification.collection.NotificationEntry +import com.android.systemui.statusbar.notification.logKey +import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_SINGLE_LINE +import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation +import com.android.systemui.statusbar.notification.row.ui.viewmodel.ConversationAvatar +import com.android.systemui.statusbar.notification.row.ui.viewmodel.ConversationData +import com.android.systemui.statusbar.notification.row.ui.viewmodel.FacePile +import com.android.systemui.statusbar.notification.row.ui.viewmodel.SingleIcon +import com.android.systemui.statusbar.notification.row.ui.viewmodel.SingleLineViewModel + +/** The inflater of SingleLineViewModel and SingleLineViewHolder */ +internal object SingleLineViewInflater { + const val TAG = "SingleLineViewInflater" + + /** + * Inflate an instance of SingleLineViewModel. + * + * @param notification the notification to show + * @param messagingStyle the MessagingStyle information is only provided for conversation + * notification, not for legacy messaging notifications + * @param builder the recovered Notification Builder + * @param systemUiContext the context of Android System UI + * @return the inflated SingleLineViewModel + */ + @JvmStatic + fun inflateSingleLineViewModel( + notification: Notification, + messagingStyle: MessagingStyle?, + builder: Notification.Builder, + systemUiContext: Context, + ): SingleLineViewModel { + if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) { + return SingleLineViewModel(null, null, null) + } + peopleHelper.init(systemUiContext) + var titleText = HybridGroupManager.resolveTitle(notification) + var contentText = HybridGroupManager.resolveText(notification) + + if (messagingStyle == null) { + return SingleLineViewModel( + titleText = titleText, + contentText = contentText, + conversationData = null, + ) + } + + val isGroupConversation = messagingStyle.isGroupConversation + + val conversationTextData = messagingStyle.loadConversationTextData(systemUiContext) + if (conversationTextData?.conversationTitle?.isNotEmpty() == true) { + titleText = conversationTextData.conversationTitle + } + if (conversationTextData?.conversationText?.isNotEmpty() == true) { + contentText = conversationTextData.conversationText + } + + val conversationAvatar = + messagingStyle.loadConversationAvatar( + notification = notification, + isGroupConversation = isGroupConversation, + builder = builder, + systemUiContext = systemUiContext + ) + + val conversationData = + ConversationData( + // We don't show the sender's name for one-to-one conversation + conversationSenderName = + if (isGroupConversation) conversationTextData?.senderName else null, + avatar = conversationAvatar + ) + + return SingleLineViewModel( + titleText = titleText, + contentText = contentText, + conversationData = conversationData, + ) + } + + /** load conversation text data from the MessagingStyle of conversation notifications */ + private fun MessagingStyle.loadConversationTextData( + systemUiContext: Context + ): ConversationTextData? { + if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) { + return null + } + var conversationText: CharSequence? + + if (messages.isEmpty()) { + return null + } + + // load the conversation text + val lastMessage = messages[messages.lastIndex] + conversationText = lastMessage.text + if (conversationText == null && lastMessage.isImageMessage()) { + conversationText = findBackUpConversationText(lastMessage, systemUiContext) + } + + // load the sender's name to display + val name = lastMessage.senderPerson?.name + val senderName = + systemUiContext.resources.getString( + R.string.conversation_single_line_name_display, + name + ) + + // We need to find back-up values for those texts if they are needed and empty + return ConversationTextData( + conversationTitle = conversationTitle + ?: findBackUpConversationTitle(senderName, systemUiContext), + conversationText = conversationText, + senderName = senderName, + ) + } + + private fun MessagingStyle.Message.isImageMessage(): Boolean = MessagingMessage.hasImage(this) + + /** find a back-up conversation title when the conversation title is null. */ + private fun MessagingStyle.findBackUpConversationTitle( + senderName: CharSequence?, + systemUiContext: Context, + ): CharSequence { + if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) { + return "" + } + return if (isGroupConversation) { + systemUiContext.resources.getString(R.string.conversation_title_fallback_group_chat) + } else { + // Is one-to-one, let's try to use the last sender's name + // The last back-up is the value of resource: conversation_title_fallback_one_to_one + senderName + ?: systemUiContext.resources.getString( + R.string.conversation_title_fallback_one_to_one + ) + } + } + + /** + * find a back-up conversation text when the conversation has null text and is image message. + */ + private fun findBackUpConversationText( + message: MessagingStyle.Message, + context: Context, + ): CharSequence? { + if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) { + return null + } + // If the message is not an image message, just return empty, the back-up text for showing + // will be SingleLineViewModel.contentText + if (!message.isImageMessage()) return null + // If is image message, return a placeholder + return context.resources.getString(R.string.conversation_single_line_image_placeholder) + } + + /** + * The text data that we load from a conversation notification to show in the single-line views. + * + * Group conversation single-line view should be formatted as: + * [conversationTitle, senderName, conversationText] + * + * One-to-one single-line view should be formatted as: + * [conversationTitle (which is equal to the senderName), conversationText] + * + * @property conversationTitle the title of the conversation, not necessarily the title of the + * notification row. conversationTitle is non-null, though may be empty, in which case we need + * to show the notification title instead. + * @property conversationText the text content of the conversation, single-line will use the + * notification's text when conversationText is null + * @property senderName the sender's name to be shown in the row when needed. senderName can be + * null + */ + data class ConversationTextData( + val conversationTitle: CharSequence, + val conversationText: CharSequence?, + val senderName: CharSequence?, + ) + + private fun groupMessages( + messages: List<MessagingStyle.Message>, + historicMessages: List<MessagingStyle.Message>, + ): List<MutableList<MessagingStyle.Message>> { + if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) { + return listOf() + } + if (messages.isEmpty() && historicMessages.isEmpty()) return listOf() + var currentGroup: MutableList<MessagingStyle.Message>? = null + var currentSenderKey: CharSequence? = null + val groups = mutableListOf<MutableList<MessagingStyle.Message>>() + for (i in 0 until (historicMessages.size + messages.size)) { + val message = if (i < historicMessages.size) historicMessages[i] else messages[i] + + val sender = message.senderPerson + val senderKey = sender?.getKeyOrName() + val isNewGroup = (currentGroup == null) || senderKey != currentSenderKey + if (isNewGroup) { + currentGroup = mutableListOf() + groups.add(currentGroup) + currentSenderKey = senderKey + } + currentGroup?.add(message) + } + return groups + } + + private fun MessagingStyle.loadConversationAvatar( + builder: Notification.Builder, + notification: Notification, + isGroupConversation: Boolean, + systemUiContext: Context, + ): ConversationAvatar { + if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) { + return SingleIcon(null) + } + val userKey = user.getKeyOrName() + var conversationIcon: Icon? = null + var conversationText: CharSequence? = conversationTitle + + val groups = groupMessages(messages, historicMessages) + val uniqueNames = peopleHelper.mapUniqueNamesToPrefixWithGroupList(groups) + + if (!isGroupConversation) { + // Conversation is one-to-one, load the single icon + // Let's resolve the icon / text from the last sender + if (shortcutIcon != null) { + conversationIcon = shortcutIcon + } + + for (i in messages.lastIndex downTo 0) { + val message = messages[i] + val sender = message.senderPerson + val senderKey = sender?.getKeyOrName() + if ((sender != null && senderKey != userKey) || i == 0) { + if (conversationText.isNullOrEmpty()) { + // We use the senderName as header text if no conversation title is provided + // (This usually happens for most 1:1 conversations) + conversationText = sender?.name ?: "" + } + if (conversationIcon == null) { + var avatarIcon = sender?.icon + if (avatarIcon == null) { + avatarIcon = builder.getDefaultAvatar(name = conversationText) + } + conversationIcon = avatarIcon + } + break + } + } + } + + if (conversationIcon == null) { + conversationIcon = notification.getLargeIcon() + } + + // If is one-to-one or the conversation has an icon, return a single icon + if (!isGroupConversation || conversationIcon != null) { + return SingleIcon(conversationIcon?.loadDrawable(systemUiContext)) + } + + // Otherwise, let's find the two last conversations to build a face pile: + var secondLastIcon: Icon? = null + var lastIcon: Icon? = null + var lastKey: CharSequence? = null + + for (i in groups.lastIndex downTo 0) { + val message = groups[i][0] + val sender = message.senderPerson ?: user + val senderKey = sender.getKeyOrName() + val notUser = senderKey != userKey + val notIncluded = senderKey != lastKey + + if ((notUser && notIncluded) || (i == 0 && lastKey == null)) { + if (lastIcon == null) { + lastIcon = + sender.icon + ?: builder.getDefaultAvatar( + name = sender.name, + uniqueNames = uniqueNames + ) + lastKey = senderKey + } else { + secondLastIcon = + sender.icon + ?: builder.getDefaultAvatar( + name = sender.name, + uniqueNames = uniqueNames + ) + break + } + } + } + + if (lastIcon == null) { + lastIcon = builder.getDefaultAvatar(name = "") + } + + if (secondLastIcon == null) { + secondLastIcon = builder.getDefaultAvatar(name = "") + } + + return FacePile( + topIconDrawable = secondLastIcon.loadDrawable(systemUiContext), + bottomIconDrawable = lastIcon.loadDrawable(systemUiContext), + bottomBackgroundColor = builder.getBackgroundColor(/* isHeader = */ false), + ) + } + + @JvmStatic + fun inflateSingleLineViewHolder( + isConversation: Boolean, + reinflateFlags: Int, + entry: NotificationEntry, + context: Context, + logger: NotificationContentInflaterLogger, + ): HybridNotificationView? { + if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) return null + if (reinflateFlags and FLAG_CONTENT_VIEW_SINGLE_LINE == 0) { + return null + } + + logger.logInflateSingleLine(entry, reinflateFlags, isConversation) + logger.logAsyncTaskProgress(entry, "inflating single-line content view") + + var view: HybridNotificationView? = null + + traceSection("NotificationContentInflater#inflateSingleLineView") { + val inflater = LayoutInflater.from(context) + val layoutRes: Int = + if (isConversation) + com.android.systemui.res.R.layout.hybrid_conversation_notification + else com.android.systemui.res.R.layout.hybrid_notification + view = inflater.inflate(layoutRes, /* root = */ null) as HybridNotificationView + if (view == null) { + Log.wtf(TAG, "Single-line view inflation result is null for entry: ${entry.logKey}") + } + } + return view + } + + private fun Notification.Builder.getDefaultAvatar( + name: CharSequence?, + uniqueNames: PeopleHelper.NameToPrefixMap? = null + ): Icon { + val layoutColor = getSmallIconColor(/* isHeader = */ false) + if (!name.isNullOrEmpty()) { + val symbol = uniqueNames?.getPrefix(name) ?: "" + return peopleHelper.createAvatarSymbol( + /* name = */ name, + /* symbol = */ symbol, + /* layoutColor = */ layoutColor + ) + } + // If name is null, create default avatar with background color + // TODO(b/319829062): Investigate caching default icon for color + return peopleHelper.createAvatarSymbol(/* name = */ "", /* symbol = */ "", layoutColor) + } + + private fun Person.getKeyOrName(): CharSequence? = if (key == null) name else key + + private val peopleHelper = PeopleHelper() +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/SingleLineConversationViewBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/SingleLineConversationViewBinder.kt new file mode 100644 index 000000000000..69284bd7ef48 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/SingleLineConversationViewBinder.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.statusbar.notification.row.ui.viewbinder + +import com.android.systemui.statusbar.notification.row.HybridConversationNotificationView +import com.android.systemui.statusbar.notification.row.HybridNotificationView +import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation +import com.android.systemui.statusbar.notification.row.ui.viewmodel.SingleLineViewModel + +object SingleLineConversationViewBinder { + @JvmStatic + fun bind(viewModel: SingleLineViewModel, view: HybridNotificationView?) { + if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) return + if (view !is HybridConversationNotificationView || !viewModel.isConversation()) { + SingleLineViewBinder.bind(viewModel, view) + return + } + + viewModel.conversationData?.avatar?.let { view.setAvatar(it) } + view.setText( + viewModel.titleText, + viewModel.contentText, + viewModel.conversationData?.conversationSenderName + ) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/SingleLineViewBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/SingleLineViewBinder.kt new file mode 100644 index 000000000000..22e10c165521 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/SingleLineViewBinder.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.statusbar.notification.row.ui.viewbinder + +import com.android.systemui.statusbar.notification.row.HybridNotificationView +import com.android.systemui.statusbar.notification.row.ui.viewmodel.SingleLineViewModel + +object SingleLineViewBinder { + @JvmStatic + fun bind(viewModel: SingleLineViewModel?, view: HybridNotificationView?) { + // bind the title and content text views + view?.apply { + bind( + /* title = */ viewModel?.titleText, + /* text = */ viewModel?.contentText, + /* contentView = */ null + ) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/SingleLineViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/SingleLineViewModel.kt new file mode 100644 index 000000000000..d583fa5d97ed --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/SingleLineViewModel.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.statusbar.notification.row.ui.viewmodel + +import android.annotation.ColorInt +import android.graphics.drawable.Drawable + +/** + * ViewModel for SingleLine Notification View. + * + * @property titleText the text of notification view title + * @property contentText the text of view content + * @property conversationData the data that is needed specifically for conversation single-line + * views. Null conversationData shows that the notification is not conversation. Legacy + * MessagingStyle Notifications doesn't have this member. + */ +data class SingleLineViewModel( + var titleText: CharSequence?, + var contentText: CharSequence?, + var conversationData: ConversationData?, +) { + fun isConversation(): Boolean { + return conversationData != null + } +} + +/** + * @property conversationSenderName the name of sender to show in the single-line view. Only group + * conversation single-line views show the sender name. + * @property avatar the avatar to show for the conversation + */ +data class ConversationData( + val conversationSenderName: CharSequence?, + val avatar: ConversationAvatar, +) + +/** + * An avatar to show for a single-line conversation notification, it can be either a single icon or + * a face pile. + */ +sealed class ConversationAvatar + +data class SingleIcon(val iconDrawable: Drawable?) : ConversationAvatar() + +/** + * A kind of avatar to show for a group conversation notification view. It consists of two avatars + * of the last two senders. + */ +data class FacePile( + val topIconDrawable: Drawable?, + val bottomIconDrawable: Drawable?, + @ColorInt val bottomBackgroundColor: Int +) : ConversationAvatar() diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainer.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainer.java index 45b9c269b61c..abf6c27c68ac 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainer.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainer.java @@ -1295,8 +1295,8 @@ public class NotificationChildrenContainer extends ViewGroup if (singleLineView != null) { minExpandHeight += singleLineView.getHeight(); } else { - Log.e(TAG, "getMinHeight: child " + child + " single line view is null", - new Exception()); + Log.e(TAG, "getMinHeight: child " + child.getEntry().getKey() + + " single line view is null", new Exception()); } visibleChildren++; } diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorTest.java index 58eec2e71d32..4519ba6d3590 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorTest.java @@ -65,6 +65,7 @@ import com.android.systemui.statusbar.notification.collection.listbuilder.plugga import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSectioner; import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener; import com.android.systemui.statusbar.notification.collection.provider.SectionStyleProvider; +import com.android.systemui.statusbar.notification.collection.render.GroupMembershipManager; import com.android.systemui.statusbar.notification.collection.render.NotifViewBarn; import com.android.systemui.statusbar.notification.row.NotifInflationErrorManager; import com.android.systemui.util.settings.SecureSettings; @@ -111,6 +112,7 @@ public class PreparationCoordinatorTest extends SysuiTestCase { @Spy private FakeNotifInflater mNotifInflater = new FakeNotifInflater(); private final SectionStyleProvider mSectionStyleProvider = new SectionStyleProvider(); @Mock private UserTracker mUserTracker; + @Mock private GroupMembershipManager mGroupMembershipManager; private NotifUiAdjustmentProvider mAdjustmentProvider; @@ -127,7 +129,9 @@ public class PreparationCoordinatorTest extends SysuiTestCase { mSecureSettings, mLockscreenUserManager, mSectionStyleProvider, - mUserTracker); + mUserTracker, + mGroupMembershipManager + ); mEntry = getNotificationEntryBuilder().setParent(ROOT_ENTRY).build(); mInflationError = new Exception(TEST_MESSAGE); mErrorManager = new NotifInflationErrorManager(); diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/inflation/NotifUiAdjustmentProviderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/inflation/NotifUiAdjustmentProviderTest.kt index f9f8d8a2cfc6..73c49c023dd5 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/inflation/NotifUiAdjustmentProviderTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/inflation/NotifUiAdjustmentProviderTest.kt @@ -17,6 +17,8 @@ package com.android.systemui.statusbar.notification.collection.inflation import android.database.ContentObserver import android.os.Handler +import android.platform.test.annotations.DisableFlags +import android.platform.test.annotations.EnableFlags import android.provider.Settings.Secure.SHOW_NOTIFICATION_SNOOZE import android.testing.AndroidTestingRunner import android.testing.TestableLooper.RunWithLooper @@ -28,6 +30,8 @@ import com.android.systemui.statusbar.notification.collection.GroupEntry import com.android.systemui.statusbar.notification.collection.NotificationEntryBuilder import com.android.systemui.statusbar.notification.collection.listbuilder.NotifSection import com.android.systemui.statusbar.notification.collection.provider.SectionStyleProvider +import com.android.systemui.statusbar.notification.collection.render.GroupMembershipManager +import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.eq import com.android.systemui.util.mockito.mock @@ -35,6 +39,8 @@ import com.android.systemui.util.mockito.withArgCaptor import com.android.systemui.util.settings.FakeSettings import com.android.systemui.util.settings.SecureSettings import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFalse +import kotlin.test.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -55,6 +61,7 @@ class NotifUiAdjustmentProviderTest : SysuiTestCase() { private val uri = FakeSettings().getUriFor(SHOW_NOTIFICATION_SNOOZE) private val dirtyListener: Runnable = mock() private val userTracker: UserTracker = mock() + private val groupMembershipManager: GroupMembershipManager = mock() private val section = NotifSection(mock(), 0) private val entry = NotificationEntryBuilder() @@ -69,7 +76,8 @@ class NotifUiAdjustmentProviderTest : SysuiTestCase() { secureSettings, lockscreenUserManager, sectionStyleProvider, - userTracker + userTracker, + groupMembershipManager, ) @Before @@ -127,4 +135,42 @@ class NotifUiAdjustmentProviderTest : SysuiTestCase() { assertThat(withSnoozing.isSnoozeEnabled).isTrue() assertThat(withSnoozing).isNotEqualTo(original) } + + @Test + @EnableFlags(AsyncHybridViewInflation.FLAG_NAME) + fun changeIsChildInGroup_asyncHybirdFlagEnabled_needReInflation() { + // Given: an Entry that is not child in group + // AsyncHybridViewInflation flag is enabled + whenever(groupMembershipManager.isChildInGroup(entry)).thenReturn(false) + val oldAdjustment = adjustmentProvider.calculateAdjustment(entry) + assertThat(oldAdjustment.isChildInGroup).isFalse() + + // When: the Entry becomes a group child + whenever(groupMembershipManager.isChildInGroup(entry)).thenReturn(true) + val newAdjustment = adjustmentProvider.calculateAdjustment(entry) + assertThat(newAdjustment.isChildInGroup).isTrue() + assertThat(newAdjustment).isNotEqualTo(oldAdjustment) + + // Then: need re-inflation + assertTrue(NotifUiAdjustment.needReinflate(oldAdjustment, newAdjustment)) + } + + @Test + @DisableFlags(AsyncHybridViewInflation.FLAG_NAME) + fun changeIsChildInGroup_asyncHybirdFlagDisabled_noNeedForReInflation() { + // Given: an Entry that is not child in group + // AsyncHybridViewInflation flag is disabled + whenever(groupMembershipManager.isChildInGroup(entry)).thenReturn(false) + val oldAdjustment = adjustmentProvider.calculateAdjustment(entry) + assertThat(oldAdjustment.isChildInGroup).isFalse() + + // When: the Entry becomes a group child + whenever(groupMembershipManager.isChildInGroup(entry)).thenReturn(true) + val newAdjustment = adjustmentProvider.calculateAdjustment(entry) + assertThat(newAdjustment.isChildInGroup).isTrue() + assertThat(newAdjustment).isNotEqualTo(oldAdjustment) + + // Then: need no re-inflation + assertFalse(NotifUiAdjustment.needReinflate(oldAdjustment, newAdjustment)) + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationContentInflaterTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationContentInflaterTest.java index b0996ad48d0a..a0d10759ba56 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationContentInflaterTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationContentInflaterTest.java @@ -88,6 +88,8 @@ public class NotificationContentInflaterTest extends SysuiTestCase { private Notification.Builder mBuilder; private ExpandableNotificationRow mRow; + private NotificationTestHelper mHelper; + @Mock private NotifRemoteViewCache mCache; @Mock private ConversationNotificationProcessor mConversationNotificationProcessor; @Mock private InflatedSmartReplyState mInflatedSmartReplyState; @@ -119,11 +121,11 @@ public class NotificationContentInflaterTest extends SysuiTestCase { .setContentTitle("Title") .setContentText("Text") .setStyle(new Notification.BigTextStyle().bigText("big text")); - NotificationTestHelper helper = new NotificationTestHelper( + mHelper = new NotificationTestHelper( mContext, mDependency, TestableLooper.get(this)); - ExpandableNotificationRow row = helper.createRow(mBuilder.build()); + ExpandableNotificationRow row = mHelper.createRow(mBuilder.build()); mRow = spy(row); when(mNotifLayoutInflaterFactoryProvider.provide(any(), any())) .thenReturn(mNotifLayoutInflaterFactory); diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/SingleLineConversationViewBinderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/SingleLineConversationViewBinderTest.kt new file mode 100644 index 000000000000..1c959af6ec3f --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/SingleLineConversationViewBinderTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.systemui.statusbar.notification.row + +import android.app.Notification +import android.app.Person +import android.platform.test.annotations.EnableFlags +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import androidx.test.filters.SmallTest +import com.android.internal.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_SINGLE_LINE +import com.android.systemui.statusbar.notification.row.SingleLineViewInflater.inflateSingleLineViewHolder +import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation +import com.android.systemui.statusbar.notification.row.ui.viewbinder.SingleLineConversationViewBinder +import com.android.systemui.util.mockito.mock +import kotlin.test.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@TestableLooper.RunWithLooper +class SingleLineConversationViewBinderTest : SysuiTestCase() { + private lateinit var notificationBuilder: Notification.Builder + private lateinit var helper: NotificationTestHelper + + @Before + fun setUp() { + allowTestableLooperAsMainThread() + helper = NotificationTestHelper(context, mDependency, TestableLooper.get(this)) + notificationBuilder = Notification.Builder(context, CHANNEL_ID) + notificationBuilder + .setSmallIcon(R.drawable.ic_corp_icon) + .setContentTitle(CONTENT_TITLE) + .setContentText(CONTENT_TEXT) + } + + @Test + @EnableFlags(AsyncHybridViewInflation.FLAG_NAME) + fun bindGroupConversationSingleLineView() { + // GIVEN a row with a group conversation notification + val user = + Person.Builder() + // .setIcon(Icon.createWithResource(mContext, + // R.drawable.ic_account_circle)) + .setName(USER_NAME) + .build() + val style = + Notification.MessagingStyle(user) + .addMessage(MESSAGE_TEXT, System.currentTimeMillis(), user) + .addMessage( + "How about lunch?", + System.currentTimeMillis(), + Person.Builder().setName("user2").build() + ) + .setGroupConversation(true) + notificationBuilder.setStyle(style).setShortcutId(SHORTCUT_ID) + val notification = notificationBuilder.build() + val row = helper.createRow(notification) + + val viewHolder = + inflateSingleLineViewHolder( + isConversation = true, + reinflateFlags = FLAG_CONTENT_VIEW_SINGLE_LINE, + entry = row.entry, + context = context, + logger = mock() + ) + as HybridConversationNotificationView + val viewModel = + SingleLineViewInflater.inflateSingleLineViewModel( + notification = notification, + messagingStyle = style, + builder = notificationBuilder, + systemUiContext = context, + ) + // WHEN: binds the viewHolder + SingleLineConversationViewBinder.bind( + viewModel, + viewHolder, + ) + + // THEN: the single-line conversation view should be bind with view model's corresponding + // fields + assertEquals(viewModel.titleText, viewHolder.titleView.text) + assertEquals(viewModel.contentText, viewHolder.textView.text) + assertEquals( + viewModel.conversationData?.conversationSenderName, + viewHolder.conversationSenderNameView.text + ) + } + + private companion object { + const val CHANNEL_ID = "CHANNEL_ID" + const val CONTENT_TITLE = "CONTENT_TITLE" + const val CONTENT_TEXT = "CONTENT_TEXT" + const val USER_NAME = "USER_NAME" + const val MESSAGE_TEXT = "MESSAGE_TEXT" + const val SHORTCUT_ID = "Shortcut" + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/SingleLineViewBinderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/SingleLineViewBinderTest.kt new file mode 100644 index 000000000000..f0fc349777b2 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/SingleLineViewBinderTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.systemui.statusbar.notification.row + +import android.app.Notification +import android.platform.test.annotations.EnableFlags +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import androidx.test.filters.SmallTest +import com.android.internal.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_SINGLE_LINE +import com.android.systemui.statusbar.notification.row.SingleLineViewInflater.inflateSingleLineViewHolder +import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation +import com.android.systemui.statusbar.notification.row.ui.viewbinder.SingleLineViewBinder +import com.android.systemui.util.mockito.mock +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@TestableLooper.RunWithLooper +class SingleLineViewBinderTest : SysuiTestCase() { + private lateinit var notificationBuilder: Notification.Builder + private lateinit var helper: NotificationTestHelper + + @Before + fun setUp() { + allowTestableLooperAsMainThread() + helper = NotificationTestHelper(mContext, mDependency, TestableLooper.get(this)) + notificationBuilder = Notification.Builder(mContext, CHANNEL_ID) + notificationBuilder + .setSmallIcon(R.drawable.ic_corp_icon) + .setContentTitle(CONTENT_TITLE) + .setContentText(CONTENT_TEXT) + } + + @Test + @EnableFlags(AsyncHybridViewInflation.FLAG_NAME) + fun bindNonConversationSingleLineView() { + // GIVEN: a row with bigText style notification + val style = Notification.BigTextStyle().bigText(CONTENT_TEXT) + notificationBuilder.setStyle(style) + val notification = notificationBuilder.build() + val row: ExpandableNotificationRow = helper.createRow(notification) + + val viewHolder = + inflateSingleLineViewHolder( + isConversation = false, + reinflateFlags = FLAG_CONTENT_VIEW_SINGLE_LINE, + entry = row.entry, + context = context, + logger = mock() + ) + val viewModel = + SingleLineViewInflater.inflateSingleLineViewModel( + notification = notification, + messagingStyle = null, + builder = notificationBuilder, + systemUiContext = context, + ) + + // WHEN: binds the viewHolder + SingleLineViewBinder.bind(viewModel, viewHolder) + + // THEN: the single-line view should be bind with viewModel's title and content text + Assert.assertEquals(viewModel.titleText, viewHolder?.titleView?.text) + Assert.assertEquals(viewModel.contentText, viewHolder?.textView?.text) + } + + private companion object { + const val CHANNEL_ID = "CHANNEL_ID" + const val CONTENT_TITLE = "A Cool New Feature" + const val CONTENT_TEXT = "Checkout out new feature!" + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/SingleLineViewInflaterTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/SingleLineViewInflaterTest.kt new file mode 100644 index 000000000000..b67153a842ac --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/SingleLineViewInflaterTest.kt @@ -0,0 +1,463 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.statusbar.notification.row + +import android.app.Notification +import android.app.Person +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.drawable.Drawable +import android.graphics.drawable.Icon +import android.platform.test.annotations.EnableFlags +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import androidx.core.graphics.drawable.toBitmap +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.res.R +import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation +import com.android.systemui.statusbar.notification.row.ui.viewmodel.ConversationAvatar +import com.android.systemui.statusbar.notification.row.ui.viewmodel.FacePile +import com.android.systemui.statusbar.notification.row.ui.viewmodel.SingleIcon +import com.android.systemui.statusbar.notification.row.ui.viewmodel.SingleLineViewModel +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertIsNot +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@TestableLooper.RunWithLooper +@EnableFlags(AsyncHybridViewInflation.FLAG_NAME) +class SingleLineViewInflaterTest : SysuiTestCase() { + private lateinit var helper: NotificationTestHelper + // Non-group MessagingStyles only have firstSender + private lateinit var firstSender: Person + private lateinit var lastSender: Person + private lateinit var firstSenderIcon: Icon + private lateinit var lastSenderIcon: Icon + private var firstSenderIconDrawable: Drawable? = null + private var lastSenderIconDrawable: Drawable? = null + private val currentUser: Person? = null + + private companion object { + const val FIRST_SENDER_NAME = "First Sender" + const val LAST_SENDER_NAME = "Second Sender" + const val LAST_MESSAGE = "How about lunch?" + + const val CONVERSATION_TITLE = "The Sender Family" + const val CONTENT_TITLE = "A Cool Group" + const val CONTENT_TEXT = "This is an amazing group chat" + + const val SHORTCUT_ID = "Shortcut" + } + + @Before + fun setUp() { + helper = NotificationTestHelper(mContext, mDependency, TestableLooper.get(this)) + firstSenderIcon = Icon.createWithBitmap(getBitmap(context, R.drawable.ic_person)) + firstSenderIconDrawable = firstSenderIcon.loadDrawable(context) + lastSenderIcon = + Icon.createWithBitmap( + getBitmap(context, com.android.internal.R.drawable.ic_account_circle) + ) + lastSenderIconDrawable = lastSenderIcon.loadDrawable(context) + firstSender = Person.Builder().setName(FIRST_SENDER_NAME).setIcon(firstSenderIcon).build() + lastSender = Person.Builder().setName(LAST_SENDER_NAME).setIcon(lastSenderIcon).build() + } + + @Test + fun createViewModelForNonConversationSingleLineView() { + // Given: a non-conversation notification + val notificationType = NonMessaging() + val notification = getNotification(NonMessaging()) + + // When: inflate the SingleLineViewModel + val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) + + // Then: the inflated SingleLineViewModel should be as expected + // conversationData: null, because it's not a conversation notification + assertEquals(SingleLineViewModel(CONTENT_TITLE, CONTENT_TEXT, null), singleLineViewModel) + } + + @Test + fun createViewModelForNonGroupConversationNotification() { + // Given: a non-group conversation notification + val notificationType = OneToOneConversation() + val notification = getNotification(notificationType) + + // When: inflate the SingleLineViewModel + val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) + + // Then: the inflated SingleLineViewModel should be as expected + // titleText: Notification.ConversationTitle + // contentText: the last message text + // conversationSenderName: null, because it's not a group conversation + // conversationData.avatar: a single icon of the last sender + assertEquals(CONVERSATION_TITLE, singleLineViewModel.titleText) + assertEquals(LAST_MESSAGE, singleLineViewModel.contentText) + assertNull( + singleLineViewModel.conversationData?.conversationSenderName, + "Sender name should be null for one-on-one conversation" + ) + assertTrue { + singleLineViewModel.conversationData + ?.avatar + ?.equalsTo(SingleIcon(firstSenderIcon.loadDrawable(context))) == true + } + } + + @Test + fun createViewModelForNonGroupLegacyMessagingStyleNotification() { + // Given: a non-group legacy messaging style notification + val notificationType = LegacyMessaging() + val notification = getNotification(notificationType) + + // When: inflate the SingleLineViewModel + val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) + + // Then: the inflated SingleLineViewModel should be as expected + // titleText: CONVERSATION_TITLE: SENDER_NAME + // contentText: the last message text + // conversationData: null, because it's not a conversation notification + assertEquals("$CONVERSATION_TITLE: $FIRST_SENDER_NAME", singleLineViewModel.titleText) + assertEquals(LAST_MESSAGE, singleLineViewModel.contentText) + assertNull( + singleLineViewModel.conversationData, + "conversationData should be null for legacy messaging conversation" + ) + } + + @Test + fun createViewModelForGroupLegacyMessagingStyleNotification() { + // Given: a non-group legacy messaging style notification + val notificationType = LegacyMessagingGroup() + val notification = getNotification(notificationType) + + // When: inflate the SingleLineViewModel + val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) + + // Then: the inflated SingleLineViewModel should be as expected + // titleText: CONVERSATION_TITLE: LAST_SENDER_NAME + // contentText: the last message text + // conversationData: null, because it's not a conversation notification + assertEquals("$CONVERSATION_TITLE: $LAST_SENDER_NAME", singleLineViewModel.titleText) + assertEquals(LAST_MESSAGE, singleLineViewModel.contentText) + assertNull( + singleLineViewModel.conversationData, + "conversationData should be null for legacy messaging conversation" + ) + } + + @Test + fun createViewModelForNonGroupConversationNotificationWithShortcutIcon() { + // Given: a non-group conversation notification with a shortcut icon + val shortcutIcon = + Icon.createWithResource(context, com.android.internal.R.drawable.ic_account_circle) + val notificationType = OneToOneConversation(shortcutIcon = shortcutIcon) + val notification = getNotification(notificationType) + + // When: inflate the SingleLineViewModel + val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) + + // Then: the inflated SingleLineViewModel should be expected + // titleText: Notification.ConversationTitle + // contentText: the last message text + // conversationSenderName: null, because it's not a group conversation + // conversationData.avatar: a single icon of the shortcut icon + assertEquals(CONVERSATION_TITLE, singleLineViewModel.titleText) + assertEquals(LAST_MESSAGE, singleLineViewModel.contentText) + assertNull( + singleLineViewModel.conversationData?.conversationSenderName, + "Sender name should be null for one-on-one conversation" + ) + assertTrue { + singleLineViewModel.conversationData + ?.avatar + ?.equalsTo(SingleIcon(shortcutIcon.loadDrawable(context))) == true + } + } + + @Test + fun createViewModelForGroupConversationNotificationWithLargeIcon() { + // Given: a group conversation notification with a large icon + val largeIcon = + Icon.createWithResource(context, com.android.internal.R.drawable.ic_account_circle) + val notificationType = GroupConversation(largeIcon = largeIcon) + val notification = getNotification(notificationType) + + // When: inflate the SingleLineViewModel + val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) + + // Then: the inflated SingleLineViewModel should be expected + // titleText: Notification.ConversationTitle + // contentText: the last message text + // conversationSenderName: the last non-user sender's name + // conversationData.avatar: a single icon + assertEquals(CONVERSATION_TITLE, singleLineViewModel.titleText) + assertEquals(LAST_MESSAGE, singleLineViewModel.contentText) + assertEquals( + context.resources.getString( + com.android.internal.R.string.conversation_single_line_name_display, + LAST_SENDER_NAME + ), + singleLineViewModel.conversationData?.conversationSenderName + ) + assertTrue { + singleLineViewModel.conversationData + ?.avatar + ?.equalsTo(SingleIcon(largeIcon.loadDrawable(context))) == true + } + } + + @Test + fun createViewModelForGroupConversationWithNoIcon() { + // Given: a group conversation notification + val notificationType = GroupConversation() + val notification = getNotification(notificationType) + + // When: inflate the SingleLineViewModel + val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) + + // Then: the inflated SingleLineViewModel should be expected + // titleText: Notification.ConversationTitle + // contentText: the last message text + // conversationSenderName: the last non-user sender's name + // conversationData.avatar: a face-pile consists the last sender's icon + assertEquals(CONVERSATION_TITLE, singleLineViewModel.titleText) + assertEquals(LAST_MESSAGE, singleLineViewModel.contentText) + assertEquals( + context.resources.getString( + com.android.internal.R.string.conversation_single_line_name_display, + LAST_SENDER_NAME + ), + singleLineViewModel.conversationData?.conversationSenderName + ) + + val backgroundColor = + Notification.Builder.recoverBuilder(context, notification) + .getBackgroundColor(/* isHeader = */ false) + assertTrue { + singleLineViewModel.conversationData + ?.avatar + ?.equalsTo( + FacePile( + firstSenderIconDrawable, + lastSenderIconDrawable, + backgroundColor, + ) + ) == true + } + } + + sealed class NotificationType(val largeIcon: Icon? = null) + + class NonMessaging(largeIcon: Icon? = null) : NotificationType(largeIcon) + + class LegacyMessaging(largeIcon: Icon? = null) : NotificationType(largeIcon) + + class LegacyMessagingGroup(largeIcon: Icon? = null) : NotificationType(largeIcon) + + class OneToOneConversation(largeIcon: Icon? = null, val shortcutIcon: Icon? = null) : + NotificationType(largeIcon) + + class GroupConversation(largeIcon: Icon? = null) : NotificationType(largeIcon) + + private fun getNotification(type: NotificationType): Notification { + val notificationBuilder: Notification.Builder = + Notification.Builder(mContext, "channelId") + .setSmallIcon(R.drawable.ic_person) + .setContentTitle(CONTENT_TITLE) + .setContentText(CONTENT_TEXT) + .setLargeIcon(type.largeIcon) + + val user = Person.Builder().setName("User").build() + + val buildMessagingStyle = + Notification.MessagingStyle(user) + .setConversationTitle(CONVERSATION_TITLE) + .addMessage("Hi", 0, currentUser) + + return when (type) { + is NonMessaging -> + notificationBuilder + .setStyle(Notification.BigTextStyle().bigText("Big Text")) + .build() + is LegacyMessaging -> { + buildMessagingStyle + .addMessage("What's up?", 0, firstSender) + .addMessage("Not much", 0, currentUser) + .addMessage(LAST_MESSAGE, 0, firstSender) + + val notification = notificationBuilder.setStyle(buildMessagingStyle).build() + + assertNull(notification.shortcutId) + notification + } + is LegacyMessagingGroup -> { + buildMessagingStyle + .addMessage("What's up?", 0, firstSender) + .addMessage("Check out my new hover board!", 0, lastSender) + .setGroupConversation(true) + .addMessage(LAST_MESSAGE, 0, lastSender) + + val notification = notificationBuilder.setStyle(buildMessagingStyle).build() + + assertNull(notification.shortcutId) + notification + } + is OneToOneConversation -> { + buildMessagingStyle + .addMessage("What's up?", 0, firstSender) + .addMessage("Not much", 0, currentUser) + .addMessage(LAST_MESSAGE, 0, firstSender) + .setShortcutIcon(type.shortcutIcon) + notificationBuilder.setShortcutId(SHORTCUT_ID).setStyle(buildMessagingStyle).build() + } + is GroupConversation -> { + buildMessagingStyle + .addMessage("What's up?", 0, firstSender) + .addMessage("Check out my new hover board!", 0, lastSender) + .setGroupConversation(true) + .addMessage(LAST_MESSAGE, 0, lastSender) + notificationBuilder.setShortcutId(SHORTCUT_ID).setStyle(buildMessagingStyle).build() + } + } + } + + private fun Notification.makeSingleLineViewModel(type: NotificationType): SingleLineViewModel { + val builder = Notification.Builder.recoverBuilder(context, this) + + // Validate the recovered builder has the right type of style + val expectMessagingStyle = + when (type) { + is LegacyMessaging, + is LegacyMessagingGroup, + is OneToOneConversation, + is GroupConversation -> true + else -> false + } + if (expectMessagingStyle) { + assertIs<Notification.MessagingStyle>( + builder.style, + "Notification style should be MessagingStyle" + ) + } else { + assertIsNot<Notification.MessagingStyle>( + builder.style, + message = "Notification style should not be MessagingStyle" + ) + } + + // Inflate the SingleLineViewModel + // Mock the behavior of NotificationContentInflater.doInBackground + val messagingStyle = builder.getMessagingStyle() + val isConversation = type is OneToOneConversation || type is GroupConversation + return SingleLineViewInflater.inflateSingleLineViewModel( + this, + if (isConversation) messagingStyle else null, + builder, + context + ) + } + + private fun Notification.Builder.getMessagingStyle(): Notification.MessagingStyle? { + return style as? Notification.MessagingStyle + } + + private fun getBitmap(context: Context, resId: Int): Bitmap { + val largeIconDimension = + context.resources.getDimension(R.dimen.conversation_single_line_avatar_size) + val d = context.resources.getDrawable(resId) + val b = + Bitmap.createBitmap( + largeIconDimension.toInt(), + largeIconDimension.toInt(), + Bitmap.Config.ARGB_8888 + ) + val c = Canvas(b) + val paint = Paint() + c.drawCircle( + largeIconDimension / 2, + largeIconDimension / 2, + largeIconDimension.coerceAtMost(largeIconDimension) / 2, + paint + ) + d.setBounds(0, 0, largeIconDimension.toInt(), largeIconDimension.toInt()) + paint.setXfermode(PorterDuffXfermode(PorterDuff.Mode.SRC_IN)) + c.saveLayer(0F, 0F, largeIconDimension, largeIconDimension, paint, Canvas.ALL_SAVE_FLAG) + d.draw(c) + c.restore() + return b + } + + fun ConversationAvatar.equalsTo(other: ConversationAvatar?): Boolean = + when { + this === other -> true + this is SingleIcon && other is SingleIcon -> equalsTo(other) + this is FacePile && other is FacePile -> equalsTo(other) + else -> false + } + + private fun SingleIcon.equalsTo(other: SingleIcon): Boolean = + iconDrawable?.equalsTo(other.iconDrawable) == true + + private fun FacePile.equalsTo(other: FacePile): Boolean = + when { + bottomBackgroundColor != other.bottomBackgroundColor -> false + topIconDrawable?.equalsTo(other.topIconDrawable) != true -> false + bottomIconDrawable?.equalsTo(other.bottomIconDrawable) != true -> false + else -> true + } + + fun Drawable.equalsTo(other: Drawable?): Boolean = + when { + this === other -> true + this.pixelsEqualTo(other) -> true + else -> false + } + + private fun <T : Drawable> T.pixelsEqualTo(t: T?) = + toBitmap().pixelsEqualTo(t?.toBitmap(), false) + + private fun Bitmap.pixelsEqualTo(otherBitmap: Bitmap?, shouldRecycle: Boolean = false) = + otherBitmap?.let { other -> + if (width == other.width && height == other.height) { + val res = toPixels().contentEquals(other.toPixels()) + if (shouldRecycle) { + doRecycle().also { otherBitmap.doRecycle() } + } + res + } else false + } + ?: kotlin.run { false } + + private fun Bitmap.toPixels() = + IntArray(width * height).apply { getPixels(this, 0, width, 0, 0, width, height) } + + fun Bitmap.doRecycle() { + if (!isRecycled) recycle() + } +} |