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