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