• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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