From c2405db679d31e0ebe21bcb84dfe7b3a45229bb5 Mon Sep 17 00:00:00 2001 From: Yining Liu Date: Tue, 31 Oct 2023 01:31:44 +0000 Subject: Move hybrid notification view inflation to the background thread Move the hybrid notification view inflation, including icon loading to the background thread to reduce janks and improve the performance of System UI. This change refactors the code structure to decouple the inflation of single-line views and the ConversationLayout RemoteViews creation. Bug: 217799515 Test: atest NotificationContentInflaterTest Flag: ACONFIG notification_async_hybrid_view_inflation DEVELOPMENT Change-Id: If1eba1bb1d66e1231f2337cd8ca023ebf0f45211 --- core/java/android/app/Notification.java | 28 ++ .../internal/widget/ConversationLayout.java | 37 +- .../com/android/internal/widget/PeopleHelper.java | 68 +++ .../notification/ConversationNotifications.kt | 5 +- .../coordinator/PreparationCoordinator.java | 8 +- .../collection/inflation/NotifInflater.kt | 31 +- .../collection/inflation/NotifUiAdjustment.kt | 7 + .../inflation/NotifUiAdjustmentProvider.kt | 5 +- .../inflation/NotificationRowBinderImpl.java | 17 + .../collection/render/ShadeViewDiffer.kt | 3 + .../statusbar/notification/row/BindStage.java | 1 + .../row/HybridConversationNotificationView.java | 99 ++++- .../notification/row/HybridGroupManager.java | 22 +- .../row/NotificationContentInflater.java | 102 ++++- .../row/NotificationContentInflaterLogger.kt | 24 ++ .../notification/row/NotificationContentView.java | 74 +++- .../row/NotificationRowContentBinder.java | 8 +- .../notification/row/RowContentBindParams.java | 4 +- .../notification/row/RowContentBindStage.java | 3 + .../notification/row/SingleLineViewInflater.kt | 390 +++++++++++++++++ .../viewbinder/SingleLineConversationViewBinder.kt | 40 ++ .../row/ui/viewbinder/SingleLineViewBinder.kt | 34 ++ .../row/ui/viewmodel/SingleLineViewModel.kt | 67 +++ .../stack/NotificationChildrenContainer.java | 4 +- .../coordinator/PreparationCoordinatorTest.java | 6 +- .../inflation/NotifUiAdjustmentProviderTest.kt | 48 ++- .../row/NotificationContentInflaterTest.java | 6 +- .../row/SingleLineConversationViewBinderTest.kt | 117 ++++++ .../notification/row/SingleLineViewBinderTest.kt | 91 ++++ .../notification/row/SingleLineViewInflaterTest.kt | 463 +++++++++++++++++++++ 30 files changed, 1751 insertions(+), 61 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/notification/row/SingleLineViewInflater.kt create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/SingleLineConversationViewBinder.kt create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewbinder/SingleLineViewBinder.kt create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/SingleLineViewModel.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/SingleLineConversationViewBinderTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/SingleLineViewBinderTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/SingleLineViewInflaterTest.kt diff --git a/core/java/android/app/Notification.java b/core/java/android/app/Notification.java index 476232cb40b3..eb357267f85f 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> groups, - List senders, boolean showSpinner) { + List senders, boolean showSpinner) { mGroups.clear(); for (int groupIndex = 0; groupIndex < groups.size(); groupIndex++) { List group = groups.get(groupIndex); @@ -963,8 +984,8 @@ public class ConversationLayout extends FrameLayout } private void findGroups(List historicMessages, - List messages, List> groups, - List senders) { + List messages, List> groups, + List senders) { CharSequence currentSenderKey = null; List 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; @@ -221,6 +223,72 @@ public class PeopleHelper { return uniqueNames; } + /** + * 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 mMap; + NameToPrefixMap(Map 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> groups) { + // Map of unique names to their prefix + ArrayMap uniqueNames = new ArrayMap<>(); + // Map of single-character string prefix to the only name which uses it, or null if multiple + ArrayMap uniqueCharacters = new ArrayMap<>(); + for (int i = 0; i < groups.size(); i++) { + List 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() 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 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 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 { @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, + historicMessages: List, + ): List> { + if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) { + return listOf() + } + if (messages.isEmpty() && historicMessages.isEmpty()) return listOf() + var currentGroup: MutableList? = null + var currentSenderKey: CharSequence? = null + val groups = mutableListOf>() + 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( + builder.style, + "Notification style should be MessagingStyle" + ) + } else { + assertIsNot( + 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.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() + } +} -- cgit v1.2.3-59-g8ed1b