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