1 /* 2 * Copyright (C) 2019 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 17 package com.android.car.messenger.common; 18 19 import android.annotation.Nullable; 20 import android.app.Notification; 21 import android.app.NotificationManager; 22 import android.app.PendingIntent; 23 import android.app.RemoteInput; 24 import android.content.Context; 25 import android.content.Intent; 26 import android.graphics.Bitmap; 27 import android.os.Bundle; 28 29 import androidx.core.app.NotificationCompat; 30 import androidx.core.app.NotificationCompat.Action; 31 import androidx.core.app.Person; 32 import androidx.core.graphics.drawable.IconCompat; 33 34 import com.android.car.telephony.common.TelecomUtils; 35 36 import java.util.ArrayList; 37 import java.util.HashMap; 38 import java.util.List; 39 import java.util.Map; 40 import java.util.function.Predicate; 41 42 /** 43 * Base Interface for Message Notification Delegates. 44 * Any Delegate who chooses to extend from this class is responsible for: 45 * <p> device connection logic </p> 46 * <p> sending and receiving messages from the connected devices </p> 47 * <p> creation of {@link ConversationNotificationInfo} and {@link Message} objects </p> 48 * <p> creation of {@link ConversationKey}, {@link MessageKey}, {@link SenderKey} </p> 49 * <p> loading of largeIcons for each Sender per device </p> 50 * <p> Mark-as-Read and Reply functionality </p> 51 **/ 52 public class BaseNotificationDelegate { 53 54 /** Used to reply to message. */ 55 public static final String ACTION_REPLY = "com.android.car.messenger.common.ACTION_REPLY"; 56 57 /** Used to clear notification state when user dismisses notification. */ 58 public static final String ACTION_DISMISS_NOTIFICATION = 59 "com.android.car.messenger.common.ACTION_DISMISS_NOTIFICATION"; 60 61 /** Used to mark a notification as read **/ 62 public static final String ACTION_MARK_AS_READ = 63 "com.android.car.messenger.common.ACTION_MARK_AS_READ"; 64 65 /* EXTRAS */ 66 /** Key under which the {@link ConversationKey} is provided. */ 67 public static final String EXTRA_CONVERSATION_KEY = 68 "com.android.car.messenger.common.EXTRA_CONVERSATION_KEY"; 69 70 /** 71 * The resultKey of the {@link RemoteInput} which is sent in the reply callback {@link 72 * Notification.Action}. 73 */ 74 public static final String EXTRA_REMOTE_INPUT_KEY = 75 "com.android.car.messenger.common.REMOTE_INPUT_KEY"; 76 77 protected final Context mContext; 78 protected NotificationManager mNotificationManager; 79 protected final boolean mUseLetterTile; 80 81 /** 82 * Maps a conversation's Notification Metadata to the conversation's unique key. 83 * The extending class should always keep this map updated with the latest new/updated 84 * notification information before calling {@link BaseNotificationDelegate#postNotification( 85 * ConversationKey, ConversationNotificationInfo, String)}. 86 **/ 87 protected final Map<ConversationKey, ConversationNotificationInfo> mNotificationInfos = 88 new HashMap<>(); 89 90 /** 91 * Maps a conversation's Notification Builder to the conversation's unique key. When the 92 * conversation gets updated, this builder should be retrieved, updated, and reposted. 93 **/ 94 private final Map<ConversationKey, NotificationCompat.Builder> mNotificationBuilders = 95 new HashMap<>(); 96 97 /** 98 * Maps a message's metadata with the message's unique key. 99 * The extending class should always keep this map updated with the latest message information 100 * before calling {@link BaseNotificationDelegate#postNotification( 101 * ConversationKey, ConversationNotificationInfo, String)}. 102 **/ 103 protected final Map<MessageKey, Message> mMessages = new HashMap<>(); 104 105 private final int mBitmapSize; 106 private final float mCornerRadiusPercent; 107 108 /** 109 * Constructor for the BaseNotificationDelegate class. 110 * @param context of the calling application. 111 * @param useLetterTile whether a letterTile icon should be used if no avatar icon is given. 112 **/ BaseNotificationDelegate(Context context, boolean useLetterTile)113 public BaseNotificationDelegate(Context context, boolean useLetterTile) { 114 mContext = context; 115 mUseLetterTile = useLetterTile; 116 mNotificationManager = 117 (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); 118 mBitmapSize = 119 mContext.getResources() 120 .getDimensionPixelSize(R.dimen.notification_contact_photo_size); 121 mCornerRadiusPercent = mContext.getResources() 122 .getFloat(R.dimen.contact_avatar_corner_radius_percent); 123 } 124 125 /** 126 * Removes all messages related to the inputted predicate, and cancels their notifications. 127 **/ cleanupMessagesAndNotifications(Predicate<CompositeKey> predicate)128 public void cleanupMessagesAndNotifications(Predicate<CompositeKey> predicate) { 129 clearNotifications(predicate); 130 mNotificationBuilders.entrySet().removeIf(entry -> predicate.test(entry.getKey())); 131 mNotificationInfos.entrySet().removeIf(entry -> predicate.test(entry.getKey())); 132 mMessages.entrySet().removeIf( 133 messageKeyMapMessageEntry -> predicate.test(messageKeyMapMessageEntry.getKey())); 134 } 135 136 /** 137 * Clears all notifications matching the predicate. Example method calls are when user 138 * wants to clear (a) message notification(s), or when the Bluetooth device that received the 139 * messages has been disconnected. 140 */ clearNotifications(Predicate<CompositeKey> predicate)141 public void clearNotifications(Predicate<CompositeKey> predicate) { 142 mNotificationInfos.forEach((conversationKey, notificationInfo) -> { 143 if (predicate.test(conversationKey)) { 144 mNotificationManager.cancel(notificationInfo.getNotificationId()); 145 } 146 }); 147 } 148 dismissInternal(ConversationKey convoKey)149 protected void dismissInternal(ConversationKey convoKey) { 150 clearNotifications(key -> key.equals(convoKey)); 151 excludeFromNotification(convoKey); 152 } 153 154 /** 155 * Excludes messages from a notification so that the messages are not shown to the user once 156 * the notification gets updated with newer messages. 157 */ excludeFromNotification(ConversationKey convoKey)158 protected void excludeFromNotification(ConversationKey convoKey) { 159 ConversationNotificationInfo info = mNotificationInfos.get(convoKey); 160 for (MessageKey key : info.mMessageKeys) { 161 Message message = mMessages.get(key); 162 message.excludeFromNotification(); 163 } 164 } 165 166 /** 167 * Helper method to add {@link Message}s to the {@link ConversationNotificationInfo}. This 168 * should be called when a new message has arrived. 169 **/ addMessageToNotificationInfo(Message message, ConversationKey convoKey)170 protected void addMessageToNotificationInfo(Message message, ConversationKey convoKey) { 171 MessageKey messageKey = new MessageKey(message); 172 boolean repeatMessage = mMessages.containsKey(messageKey); 173 mMessages.put(messageKey, message); 174 if (!repeatMessage) { 175 ConversationNotificationInfo notificationInfo = mNotificationInfos.get(convoKey); 176 notificationInfo.mMessageKeys.add(messageKey); 177 } 178 } 179 180 /** 181 * Creates a new notification, or updates an existing notification with the latest messages, 182 * then posts it. 183 * This should be called after the {@link ConversationNotificationInfo} object has been created, 184 * and all of its {@link Message} objects have been linked to it. 185 **/ postNotification(ConversationKey conversationKey, ConversationNotificationInfo notificationInfo, String channelId, @Nullable Bitmap avatarIcon)186 protected void postNotification(ConversationKey conversationKey, 187 ConversationNotificationInfo notificationInfo, String channelId, 188 @Nullable Bitmap avatarIcon) { 189 boolean newNotification = !mNotificationBuilders.containsKey(conversationKey); 190 191 NotificationCompat.Builder builder = newNotification ? new NotificationCompat.Builder( 192 mContext, channelId) : mNotificationBuilders.get( 193 conversationKey); 194 builder.setChannelId(channelId); 195 Message lastMessage = mMessages.get(notificationInfo.mMessageKeys.getLast()); 196 197 builder.setContentTitle(notificationInfo.getConvoTitle()); 198 builder.setContentText(mContext.getResources().getQuantityString( 199 R.plurals.notification_new_message, notificationInfo.mMessageKeys.size(), 200 notificationInfo.mMessageKeys.size())); 201 202 if (avatarIcon != null) { 203 builder.setLargeIcon(avatarIcon); 204 } else if (mUseLetterTile) { 205 builder.setLargeIcon(TelecomUtils.createLetterTile(mContext, 206 Utils.getInitials(lastMessage.getSenderName(), ""), 207 lastMessage.getSenderName(), mBitmapSize, mCornerRadiusPercent).getBitmap()); 208 } 209 // Else, no avatar icon will be shown. 210 211 builder.setWhen(lastMessage.getReceivedTime()); 212 213 // Create MessagingStyle 214 String userName = (notificationInfo.getUserDisplayName() == null 215 || notificationInfo.getUserDisplayName().isEmpty()) ? mContext.getString( 216 R.string.name_not_available) : notificationInfo.getUserDisplayName(); 217 Person user = new Person.Builder() 218 .setName(userName) 219 .build(); 220 NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle( 221 user); 222 Person sender = new Person.Builder() 223 .setName(lastMessage.getSenderName()) 224 .setUri(lastMessage.getSenderContactUri()) 225 .build(); 226 notificationInfo.mMessageKeys.stream().map(mMessages::get).forEachOrdered(message -> { 227 if (!message.shouldExcludeFromNotification()) { 228 messagingStyle.addMessage( 229 message.getMessageText(), 230 message.getReceivedTime(), 231 notificationInfo.isGroupConvo() ? new Person.Builder() 232 .setName(message.getSenderName()) 233 .setUri(message.getSenderContactUri()) 234 .build() : sender); 235 } 236 }); 237 if (notificationInfo.isGroupConvo()) { 238 messagingStyle.setConversationTitle( 239 mContext.getString(R.string.group_conversation_title_separator, 240 lastMessage.getSenderName(), notificationInfo.getConvoTitle())); 241 } 242 243 // We are creating this notification for the first time. 244 if (newNotification) { 245 builder.setCategory(Notification.CATEGORY_MESSAGE); 246 if (notificationInfo.getAppIcon() != null) { 247 builder.setSmallIcon(IconCompat.createFromIcon(notificationInfo.getAppIcon())); 248 } else { 249 builder.setSmallIcon(R.drawable.ic_message); 250 } 251 252 builder.setShowWhen(true); 253 messagingStyle.setGroupConversation(notificationInfo.isGroupConvo()); 254 255 if (notificationInfo.getAppDisplayName() != null) { 256 Bundle displayName = new Bundle(); 257 displayName.putCharSequence(Notification.EXTRA_SUBSTITUTE_APP_NAME, 258 notificationInfo.getAppDisplayName()); 259 builder.addExtras(displayName); 260 } 261 262 PendingIntent deleteIntent = createServiceIntent(conversationKey, 263 notificationInfo.getNotificationId(), 264 ACTION_DISMISS_NOTIFICATION); 265 builder.setDeleteIntent(deleteIntent); 266 267 List<Action> actions = buildNotificationActions(conversationKey, 268 notificationInfo.getNotificationId()); 269 for (final Action action : actions) { 270 builder.addAction(action); 271 } 272 } 273 builder.setStyle(messagingStyle); 274 275 mNotificationBuilders.put(conversationKey, builder); 276 mNotificationManager.notify(notificationInfo.getNotificationId(), builder.build()); 277 } 278 279 /** Can be overridden by any Delegates that have some devices that do not support reply. **/ shouldAddReplyAction(String deviceAddress)280 protected boolean shouldAddReplyAction(String deviceAddress) { 281 return true; 282 } 283 buildNotificationActions(ConversationKey conversationKey, int notificationId)284 private List<Action> buildNotificationActions(ConversationKey conversationKey, 285 int notificationId) { 286 final int icon = android.R.drawable.ic_media_play; 287 288 final List<NotificationCompat.Action> actionList = new ArrayList<>(); 289 290 // Reply action 291 if (shouldAddReplyAction(conversationKey.getDeviceId())) { 292 final String replyString = mContext.getString(R.string.action_reply); 293 PendingIntent replyIntent = createServiceIntent(conversationKey, notificationId, 294 ACTION_REPLY); 295 actionList.add( 296 new NotificationCompat.Action.Builder(icon, replyString, replyIntent) 297 .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) 298 .setShowsUserInterface(false) 299 .addRemoteInput( 300 new androidx.core.app.RemoteInput.Builder( 301 EXTRA_REMOTE_INPUT_KEY) 302 .build() 303 ) 304 .build() 305 ); 306 } 307 308 // Mark-as-read Action. This will be the callback of Notification Center's "Read" action. 309 final String markAsRead = mContext.getString(R.string.action_mark_as_read); 310 PendingIntent markAsReadIntent = createServiceIntent(conversationKey, notificationId, 311 ACTION_MARK_AS_READ); 312 actionList.add( 313 new NotificationCompat.Action.Builder(icon, markAsRead, markAsReadIntent) 314 .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) 315 .setShowsUserInterface(false) 316 .build() 317 ); 318 319 return actionList; 320 } 321 createServiceIntent(ConversationKey conversationKey, int notificationId, String action)322 private PendingIntent createServiceIntent(ConversationKey conversationKey, int notificationId, 323 String action) { 324 Intent intent = new Intent(mContext, mContext.getClass()) 325 .setAction(action) 326 .setClassName(mContext, mContext.getClass().getName()) 327 .putExtra(EXTRA_CONVERSATION_KEY, conversationKey); 328 329 return PendingIntent.getForegroundService(mContext, notificationId, intent, 330 PendingIntent.FLAG_UPDATE_CURRENT); 331 } 332 333 } 334