1 /*
2  * Copyright (C) 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.car.notification.template;
17 
18 import static android.app.Notification.EXTRA_IS_GROUP_CONVERSATION;
19 
20 import android.annotation.Nullable;
21 import android.app.Notification;
22 import android.app.Person;
23 import android.graphics.drawable.Drawable;
24 import android.graphics.drawable.Icon;
25 import android.os.Build;
26 import android.os.Bundle;
27 import android.os.Parcelable;
28 import android.service.notification.StatusBarNotification;
29 import android.text.TextUtils;
30 import android.util.Log;
31 import android.view.View;
32 
33 import androidx.annotation.ColorInt;
34 import androidx.core.app.NotificationCompat.MessagingStyle;
35 
36 import com.android.car.notification.AlertEntry;
37 import com.android.car.notification.NotificationClickHandlerFactory;
38 import com.android.car.notification.PreprocessingManager;
39 import com.android.car.notification.R;
40 
41 import java.util.List;
42 
43 /**
44  * Messaging notification template that displays a messaging notification and a voice reply button.
45  */
46 public class MessageNotificationViewHolder extends CarNotificationBaseViewHolder {
47     private static final String TAG = "MessageNotificationViewHolder";
48     private static final boolean DEBUG = Build.IS_DEBUGGABLE;
49     private static final String SENDER_TITLE_SEPARATOR = " • ";
50     private static final String SENDER_BODY_SEPARATOR = ": ";
51     private static final String NEW_LINE = "\n";
52 
53     private final CarNotificationBodyView mBodyView;
54     private final CarNotificationHeaderView mHeaderView;
55     private final CarNotificationActionsView mActionsView;
56     private final PreprocessingManager mPreprocessingManager;
57     private final String mNewMessageText;
58     private final String mSeeMoreText;
59     private final String mEllipsizedSuffix;
60     private final int mMaxMessageCount;
61     private final int mMaxLineCount;
62     private final int mAdditionalCharCountAfterExpansion;
63     private final Drawable mGroupIcon;
64     private final boolean mUseCustomColorForMessageNotificationCountTextButton;
65     private final float mDisabledCountTextButtonAlpha;
66     @ColorInt
67     private final int mCountTextColor;
68 
69     private final NotificationClickHandlerFactory mClickHandlerFactory;
70 
MessageNotificationViewHolder( View view, NotificationClickHandlerFactory clickHandlerFactory)71     public MessageNotificationViewHolder(
72             View view, NotificationClickHandlerFactory clickHandlerFactory) {
73         super(view, clickHandlerFactory);
74         mHeaderView = view.findViewById(R.id.notification_header);
75         mActionsView = view.findViewById(R.id.notification_actions);
76         mBodyView = view.findViewById(R.id.notification_body);
77 
78         mNewMessageText = getContext().getString(R.string.restricted_hun_message_content);
79         mSeeMoreText = getContext().getString(R.string.see_more_message);
80         mEllipsizedSuffix = getContext().getString(R.string.ellipsized_string);
81         mMaxMessageCount =
82                 getContext().getResources().getInteger(R.integer.config_maxNumberOfMessagesInPanel);
83         mMaxLineCount = getContext().getResources().getInteger(
84                 R.integer.config_maxNumberOfMessageLinesInPanel);
85         mAdditionalCharCountAfterExpansion = getContext().getResources().getInteger(
86                 R.integer.config_additionalCharactersToShowInSingleMessageExpandedNotification);
87         mGroupIcon = getContext().getDrawable(R.drawable.ic_group);
88         mUseCustomColorForMessageNotificationCountTextButton =
89                 getContext().getResources().getBoolean(
90                         R.bool.config_useCustomColorForMessageNotificationCountTextButton);
91         mCountTextColor = getContext().getResources().getColor(R.color.count_text);
92         mDisabledCountTextButtonAlpha = getContext().getResources().getFloat(
93                 R.dimen.config_disabledCountTextButtonAlpha);
94 
95         mClickHandlerFactory = clickHandlerFactory;
96         mPreprocessingManager = PreprocessingManager.getInstance(getContext());
97     }
98 
99     /**
100      * Binds a {@link AlertEntry} to a messaging car notification template without
101      * UX restriction.
102      */
103     @Override
bind(AlertEntry alertEntry, boolean isInGroup, boolean isHeadsUp, boolean isSeen)104     public void bind(AlertEntry alertEntry, boolean isInGroup,
105             boolean isHeadsUp, boolean isSeen) {
106         super.bind(alertEntry, isInGroup, isHeadsUp, isSeen);
107         bindBody(alertEntry, isInGroup, /* isRestricted= */ false, isHeadsUp);
108         mHeaderView.bind(alertEntry, isInGroup);
109         mActionsView.bind(mClickHandlerFactory, alertEntry);
110     }
111 
112     /**
113      * Binds a {@link AlertEntry} to a messaging car notification template with
114      * UX restriction.
115      */
bindRestricted(AlertEntry alertEntry, boolean isInGroup, boolean isHeadsUp, boolean isSeen)116     public void bindRestricted(AlertEntry alertEntry, boolean isInGroup, boolean isHeadsUp,
117             boolean isSeen) {
118         super.bind(alertEntry, isInGroup, isHeadsUp, isSeen);
119         bindBody(alertEntry, isInGroup, /* isRestricted= */ true, isHeadsUp);
120         mHeaderView.bind(alertEntry, isInGroup);
121 
122         mActionsView.bind(mClickHandlerFactory, alertEntry);
123     }
124 
125     /**
126      * Private method that binds the data to the view.
127      */
bindBody(AlertEntry alertEntry, boolean isInGroup, boolean isRestricted, boolean isHeadsUp)128     private void bindBody(AlertEntry alertEntry, boolean isInGroup, boolean isRestricted,
129             boolean isHeadsUp) {
130         if (DEBUG) {
131             if (isInGroup) {
132                 Log.d(TAG, "Is part of notification group: " + alertEntry);
133             } else {
134                 Log.d(TAG, "Is not part of notification group: " + alertEntry);
135             }
136             if (isRestricted) {
137                 Log.d(TAG, "Has driver restrictions: " + alertEntry);
138             } else {
139                 Log.d(TAG, "Doesn't have driver restrictions: " + alertEntry);
140             }
141             if (isHeadsUp) {
142                 Log.d(TAG, "Is a heads-up notification: " + alertEntry);
143             } else {
144                 Log.d(TAG, "Is not a heads-up notification: " + alertEntry);
145             }
146         }
147 
148         if (mUseCustomColorForMessageNotificationCountTextButton) {
149             mBodyView.setCountTextColor(mCountTextColor);
150         } else {
151             mBodyView.setCountTextColor(getAccentColor());
152         }
153 
154         mBodyView.setCountTextAlpha(isRestricted ? mDisabledCountTextButtonAlpha : /* alpha= */ 1);
155 
156         Notification notification = alertEntry.getNotification();
157         StatusBarNotification sbn = alertEntry.getStatusBarNotification();
158         Bundle extras = notification.extras;
159         CharSequence messageText;
160         CharSequence conversationTitle;
161         Icon avatar = null;
162         Integer messageCount;
163         CharSequence senderName = null;
164         Notification.MessagingStyle.Message latestMessage = null;
165 
166         MessagingStyle messagingStyle =
167                 MessagingStyle.extractMessagingStyleFromNotification(notification);
168 
169         boolean isGroupConversation =
170                 ((messagingStyle != null && messagingStyle.isGroupConversation())
171                         || extras.getBoolean(EXTRA_IS_GROUP_CONVERSATION));
172         if (DEBUG) {
173             if (isGroupConversation) {
174                 Log.d(TAG, "Is a group conversation: " + alertEntry);
175             } else {
176                 Log.d(TAG, "Is not a group conversation: " + alertEntry);
177             }
178         }
179 
180         boolean messageStyleFlag = false;
181         List<Notification.MessagingStyle.Message> messages = null;
182         Parcelable[] messagesData = extras.getParcelableArray(Notification.EXTRA_MESSAGES);
183         if (messagesData != null) {
184             messages = Notification.MessagingStyle.Message.getMessagesFromBundleArray(messagesData);
185             if (messages != null && !messages.isEmpty()) {
186                 if (DEBUG) {
187                     Log.d(TAG, "App did use messaging style: " + alertEntry);
188                 }
189                 messageStyleFlag = true;
190 
191                 // Use the latest message
192                 latestMessage = messages.get(messages.size() - 1);
193                 Person sender = latestMessage.getSenderPerson();
194                 if (sender != null) {
195                     avatar = sender.getIcon();
196                 }
197                 senderName = (sender != null ? sender.getName() : latestMessage.getSender());
198             } else {
199                 // App did not use messaging style; fall back to standard fields
200                 if (DEBUG) {
201                     Log.d(TAG, "App did not use messaging style; fall back to standard "
202                             + "fields: " + alertEntry);
203                 }
204             }
205         }
206 
207 
208         messageCount = getMessageCount(messages, notification.number);
209         messageText = getMessageText(latestMessage, isRestricted, isHeadsUp, isGroupConversation,
210                 senderName, messageCount, extras);
211         conversationTitle = getConversationTitle(messagingStyle, isHeadsUp, isGroupConversation,
212                 senderName, extras);
213 
214         if (avatar == null) {
215             avatar = notification.getLargeIcon();
216         }
217 
218         Long when;
219         if (notification.showsTime()) {
220             when = notification.when;
221         } else {
222             when = null;
223         }
224 
225         Drawable groupIcon;
226         if (isGroupConversation) {
227             groupIcon = mGroupIcon;
228         } else {
229             groupIcon = null;
230         }
231 
232         int unshownCount = messageCount - 1;
233         String unshownCountText = null;
234         if (!isRestricted && !isHeadsUp && messageStyleFlag) {
235             if (unshownCount > 0) {
236                 unshownCountText = getContext().getResources().getQuantityString(
237                         R.plurals.restricted_numbered_message_content, unshownCount, unshownCount);
238             } else if (messageText.toString().endsWith(mEllipsizedSuffix)) {
239                 unshownCountText = mSeeMoreText;
240             }
241 
242             View.OnClickListener listener =
243                     getCountViewOnClickListener(unshownCount, messages, isGroupConversation,
244                             sbn, conversationTitle, avatar, groupIcon, when);
245             mBodyView.setCountOnClickListener(listener);
246         }
247         mBodyView.bind(conversationTitle, messageText,
248                 sbn, avatar, groupIcon, unshownCountText, when);
249     }
250 
getMessageText(Notification.MessagingStyle.Message message, boolean isRestricted, boolean isHeadsUp, boolean isGroupConversation, CharSequence senderName, int messageCount, Bundle extras)251     private CharSequence getMessageText(Notification.MessagingStyle.Message message,
252             boolean isRestricted, boolean isHeadsUp, boolean isGroupConversation,
253             CharSequence senderName, int messageCount, Bundle extras) {
254         CharSequence messageText = null;
255 
256         if (message != null) {
257             if (DEBUG) {
258                 Log.d(TAG, "Message style message text used.");
259             }
260 
261             messageText = message.getText();
262 
263             if (!isHeadsUp && isGroupConversation) {
264                 // If conversation is a group conversation and notification is not a HUN,
265                 // then prepend sender's name to title.
266                 messageText = senderName + SENDER_BODY_SEPARATOR + messageText;
267             }
268         } else {
269             if (DEBUG) {
270                 Log.d(TAG, "Standard field message text used.");
271             }
272 
273             messageText = extras.getCharSequence(Notification.EXTRA_TEXT);
274         }
275 
276         if (isRestricted) {
277             if (isHeadsUp || messageCount == 1) {
278                 messageText = mNewMessageText;
279             } else {
280                 messageText = getContext().getResources().getQuantityString(
281                         R.plurals.restricted_numbered_message_content, messageCount, messageCount);
282             }
283         }
284 
285         if (!TextUtils.isEmpty(messageText)) {
286             messageText = mPreprocessingManager.trimText(messageText);
287         }
288 
289         return messageText;
290     }
291 
getConversationTitle(MessagingStyle messagingStyle, boolean isHeadsUp, boolean isGroupConversation, CharSequence senderName, Bundle extras)292     private CharSequence getConversationTitle(MessagingStyle messagingStyle, boolean isHeadsUp,
293             boolean isGroupConversation, CharSequence senderName, Bundle extras) {
294         CharSequence conversationTitle = null;
295 
296         if (messagingStyle != null) {
297             conversationTitle = messagingStyle.getConversationTitle();
298         }
299 
300         if (isGroupConversation && conversationTitle != null && isHeadsUp) {
301             // If conversation title has been set, conversation is a group conversation
302             // and notification is a HUN, then prepend sender's name to title.
303             conversationTitle = senderName + SENDER_TITLE_SEPARATOR + conversationTitle;
304         } else if (conversationTitle == null) {
305             if (DEBUG) {
306                 Log.d(TAG, "Conversation title not set.");
307             }
308             // If conversation title has not been set, set it as sender's name.
309             conversationTitle = senderName;
310         }
311 
312         if (TextUtils.isEmpty(conversationTitle)) {
313             if (DEBUG) {
314                 Log.d(TAG, "Standard field conversation title used.");
315             }
316             conversationTitle = extras.getCharSequence(Notification.EXTRA_TITLE);
317         }
318 
319         return conversationTitle;
320     }
321 
getMessageCount(List<Notification.MessagingStyle.Message> messages, int numEvents)322     private int getMessageCount(List<Notification.MessagingStyle.Message> messages, int numEvents) {
323         Integer messageCount = null;
324 
325         if (messages != null) {
326             messageCount = messages.size();
327         } else {
328             messageCount = numEvents;
329             if (messageCount == 0) {
330                 // A notification should at least represent 1 message
331                 messageCount = 1;
332             }
333         }
334 
335         return messageCount;
336     }
337 
338     @Override
reset()339     void reset() {
340         super.reset();
341         mBodyView.reset();
342         mHeaderView.reset();
343         mActionsView.reset();
344     }
345 
getCountViewOnClickListener(int unshownCount, @Nullable List<Notification.MessagingStyle.Message> messages, boolean isGroupConversation, StatusBarNotification sbn, CharSequence title, @Nullable Icon avatar, @Nullable Drawable groupIcon, @Nullable Long when)346     private View.OnClickListener getCountViewOnClickListener(int unshownCount,
347             @Nullable List<Notification.MessagingStyle.Message> messages,
348             boolean isGroupConversation, StatusBarNotification sbn, CharSequence title,
349             @Nullable Icon avatar, @Nullable Drawable groupIcon, @Nullable Long when) {
350         String finalMessage;
351         if (unshownCount > 0) {
352             StringBuilder builder = new StringBuilder();
353             for (int i = messages.size() - 1; i >= messages.size() - 1 - mMaxMessageCount && i >= 0;
354                     i--) {
355                 if (i != messages.size() - 1) {
356                     builder.append(NEW_LINE);
357                     builder.append(NEW_LINE);
358                 }
359                 unshownCount--;
360                 Notification.MessagingStyle.Message message = messages.get(i);
361                 Person sender = message.getSenderPerson();
362                 CharSequence senderName =
363                         (sender != null ? sender.getName() : message.getSender());
364                 if (isGroupConversation) {
365                     builder.append(senderName + SENDER_BODY_SEPARATOR + message.getText());
366                 } else {
367                     builder.append(message.getText());
368                 }
369                 if (builder.toString().split(NEW_LINE).length >= mMaxLineCount) {
370                     break;
371                 }
372             }
373 
374             finalMessage = builder.toString();
375         } else {
376             StringBuilder builder = new StringBuilder();
377             Notification.MessagingStyle.Message message = messages.get(messages.size() - 1);
378             Person sender = message.getSenderPerson();
379             CharSequence senderName =
380                     (sender != null ? sender.getName() : message.getSender());
381             if (isGroupConversation) {
382                 builder.append(senderName + SENDER_BODY_SEPARATOR + message.getText());
383             } else {
384                 builder.append(message.getText());
385             }
386             String messageStr = builder.toString();
387 
388             int maxCharCountAfterExpansion;
389             if (mPreprocessingManager.getMaximumStringLength() == Integer.MAX_VALUE) {
390                 maxCharCountAfterExpansion = Integer.MAX_VALUE;
391             } else {
392                 // We are exceeding UXRE maximum string length limit only when expanding the long
393                 // message notification. This neither applies for collapsed single message
394                 // notifications nor applies for UXRE updates that are handled by `isRestricted`
395                 // being {@code true}.
396                 maxCharCountAfterExpansion = mPreprocessingManager.getMaximumStringLength()
397                         + mAdditionalCharCountAfterExpansion - mEllipsizedSuffix.length();
398             }
399 
400             if (messageStr.length() > maxCharCountAfterExpansion) {
401                 messageStr = messageStr.substring(0, maxCharCountAfterExpansion - 1)
402                         + mEllipsizedSuffix;
403             }
404             finalMessage = messageStr;
405         }
406 
407         int finalUnshownCount = unshownCount;
408 
409         return view -> {
410             String unshownCountText;
411             if (finalUnshownCount <= 0) {
412                 unshownCountText = null;
413             } else {
414                 unshownCountText = getContext().getResources().getQuantityString(
415                         R.plurals.message_unshown_count, finalUnshownCount, finalUnshownCount);
416             }
417 
418             mBodyView.bind(title, finalMessage, sbn, avatar, groupIcon,
419                     unshownCountText, when);
420             mBodyView.setContentMaxLines(mMaxLineCount);
421             mBodyView.setCountOnClickListener(null);
422             mBodyView.setCountTextAlpha(mDisabledCountTextButtonAlpha);
423         };
424     }
425 }
426