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