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