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