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.companiondevicesupport.feature.notificationmsg;
18 
19 import static com.android.car.connecteddevice.util.SafeLog.logd;
20 import static com.android.car.connecteddevice.util.SafeLog.logw;
21 
22 import android.app.NotificationChannel;
23 import android.app.NotificationManager;
24 import android.content.Context;
25 import android.graphics.Bitmap;
26 import android.graphics.BitmapFactory;
27 import android.media.AudioAttributes;
28 import android.provider.Settings;
29 
30 import androidx.annotation.Nullable;
31 
32 import com.android.car.companiondevicesupport.api.external.CompanionDevice;
33 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.Action;
34 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.AvatarIconSync;
35 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.CarToPhoneMessage;
36 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.ClearAppDataRequest;
37 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.ConversationNotification;
38 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MapEntry;
39 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MessagingStyleMessage;
40 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.PhoneToCarMessage;
41 import com.android.car.messenger.common.BaseNotificationDelegate;
42 import com.android.car.messenger.common.ConversationKey;
43 import com.android.car.messenger.common.ConversationNotificationInfo;
44 import com.android.car.messenger.common.Message;
45 import com.android.car.messenger.common.ProjectionStateListener;
46 import com.android.car.messenger.common.SenderKey;
47 import com.android.car.messenger.common.Utils;
48 import com.android.internal.annotations.VisibleForTesting;
49 
50 import java.util.HashMap;
51 import java.util.List;
52 import java.util.Map;
53 
54 /**
55  * Posts Message notifications sent from the {@link CompanionDevice}, and relays user interaction
56  *  with the messages back to the device.
57  **/
58 public class NotificationMsgDelegate extends BaseNotificationDelegate {
59     private static final String TAG = "NotificationMsgDelegate";
60 
61     /** Key for the Reply string in a {@link MapEntry}. **/
62     protected static final String REPLY_KEY = "REPLY";
63     /**
64      * Value for {@link ClearAppDataRequest#getMessagingAppPackageName()}, representing
65      * when all messaging applications' data should be removed.
66      */
67     protected static final String REMOVE_ALL_APP_DATA = "ALL";
68 
69     private static final AudioAttributes AUDIO_ATTRIBUTES = new AudioAttributes.Builder()
70             .setUsage(AudioAttributes.USAGE_NOTIFICATION)
71             .build();
72 
73     private Map<String, NotificationChannelWrapper> mAppNameToChannel = new HashMap<>();
74 
75     /**
76      * The Bluetooth Device address of the connected device. NOTE: this is NOT the same as
77      * {@link CompanionDevice#getDeviceId()}.
78      */
79     private String mConnectedDeviceBluetoothAddress;
80     /**
81      * Maps a Bitmap of a sender's Large Icon to the sender's unique key for 1-1 conversations.
82      **/
83     protected final Map<SenderKey, Bitmap> mOneOnOneConversationAvatarMap = new HashMap<>();
84 
85     /** Tracks whether a projection application is active in the foreground. **/
86     private ProjectionStateListener mProjectionStateListener;
87 
NotificationMsgDelegate(Context context)88     public NotificationMsgDelegate(Context context) {
89         super(context, /* useLetterTile */ false);
90         mProjectionStateListener = new ProjectionStateListener(context);
91     }
92 
onMessageReceived(CompanionDevice device, PhoneToCarMessage message)93     public void onMessageReceived(CompanionDevice device, PhoneToCarMessage message) {
94         String notificationKey = message.getNotificationKey();
95 
96         switch (message.getMessageDataCase()) {
97             case CONVERSATION:
98                 initializeNewConversation(device, message.getConversation(), notificationKey);
99                 return;
100             case MESSAGE:
101                 initializeNewMessage(device.getDeviceId(), message.getMessage(), notificationKey);
102                 return;
103             case STATUS_UPDATE:
104                 // TODO (b/144924164): implement Action Request tracking logic.
105                 return;
106             case AVATAR_ICON_SYNC:
107                 storeIcon(new ConversationKey(device.getDeviceId(), notificationKey),
108                         message.getAvatarIconSync());
109                 return;
110             case PHONE_METADATA:
111                 mConnectedDeviceBluetoothAddress =
112                         message.getPhoneMetadata().getBluetoothDeviceAddress();
113                 return;
114             case CLEAR_APP_DATA_REQUEST:
115                 clearAppData(device.getDeviceId(),
116                         message.getClearAppDataRequest().getMessagingAppPackageName());
117                 return;
118             case FEATURE_ENABLED_STATE_CHANGE:
119                 // TODO(b/150326327): implement enabled state change behavior.
120                 return;
121             case MESSAGEDATA_NOT_SET:
122             default:
123                 logw(TAG, "PhoneToCarMessage: message data not set!");
124         }
125     }
126 
dismiss(ConversationKey convoKey)127     protected CarToPhoneMessage dismiss(ConversationKey convoKey) {
128         super.dismissInternal(convoKey);
129         // TODO(b/144924164): add a request id to the action.
130         Action action = Action.newBuilder()
131                 .setActionName(Action.ActionName.DISMISS)
132                 .setNotificationKey(convoKey.getSubKey())
133                 .build();
134         return CarToPhoneMessage.newBuilder()
135                 .setNotificationKey(convoKey.getSubKey())
136                 .setActionRequest(action)
137                 .build();
138     }
139 
markAsRead(ConversationKey convoKey)140     protected CarToPhoneMessage markAsRead(ConversationKey convoKey) {
141         excludeFromNotification(convoKey);
142         // TODO(b/144924164): add a request id to the action.
143         Action action = Action.newBuilder()
144                 .setActionName(Action.ActionName.MARK_AS_READ)
145                 .setNotificationKey(convoKey.getSubKey())
146                 .build();
147         return CarToPhoneMessage.newBuilder()
148                 .setNotificationKey(convoKey.getSubKey())
149                 .setActionRequest(action)
150                 .build();
151     }
152 
reply(ConversationKey convoKey, String message)153     protected CarToPhoneMessage reply(ConversationKey convoKey, String message) {
154         // TODO(b/144924164): add a request id to the action.
155         MapEntry entry = MapEntry.newBuilder()
156                 .setKey(REPLY_KEY)
157                 .setValue(message)
158                 .build();
159         Action action = Action.newBuilder()
160                 .setActionName(Action.ActionName.REPLY)
161                 .setNotificationKey(convoKey.getSubKey())
162                 .addMapEntry(entry)
163                 .build();
164         return CarToPhoneMessage.newBuilder()
165                 .setNotificationKey(convoKey.getSubKey())
166                 .setActionRequest(action)
167                 .build();
168     }
169 
onDestroy()170     protected void onDestroy() {
171         // Erase all the notifications and local data, so that no user data stays on the device
172         // after the feature is stopped.
173         cleanupMessagesAndNotifications(key -> true);
174         mProjectionStateListener.destroy();
175         mOneOnOneConversationAvatarMap.clear();
176         mAppNameToChannel.clear();
177         mConnectedDeviceBluetoothAddress = null;
178     }
179 
onDeviceDisconnected(String deviceId)180     protected void onDeviceDisconnected(String deviceId) {
181         mConnectedDeviceBluetoothAddress = null;
182         cleanupMessagesAndNotifications(key -> key.matches(deviceId));
183         mOneOnOneConversationAvatarMap.entrySet().removeIf(
184                 conversationKey -> conversationKey.getKey().matches(deviceId));
185     }
186 
initializeNewConversation(CompanionDevice device, ConversationNotification notification, String notificationKey)187     private void initializeNewConversation(CompanionDevice device,
188             ConversationNotification notification, String notificationKey) {
189         String deviceAddress = device.getDeviceId();
190         ConversationKey convoKey = new ConversationKey(deviceAddress, notificationKey);
191 
192         if (!Utils.isValidConversationNotification(notification, /* isShallowCheck= */ false)) {
193             logd(TAG, "Failed to initialize new Conversation, object missing required fields");
194             return;
195         }
196 
197         ConversationNotificationInfo convoInfo;
198         if (mNotificationInfos.containsKey(convoKey)) {
199             logw(TAG, "Conversation already exists! " + notificationKey);
200             convoInfo = mNotificationInfos.get(convoKey);
201         } else {
202             convoInfo = ConversationNotificationInfo.
203                     createConversationNotificationInfo(device.getDeviceName(), device.getDeviceId(),
204                             notification, notificationKey);
205             mNotificationInfos.put(convoKey, convoInfo);
206         }
207 
208 
209         String appDisplayName = convoInfo.getAppDisplayName();
210 
211         List<MessagingStyleMessage> messages =
212                 notification.getMessagingStyle().getMessagingStyleMsgList();
213         MessagingStyleMessage latestMessage = messages.get(0);
214         for (MessagingStyleMessage messagingStyleMessage : messages) {
215             createNewMessage(deviceAddress, messagingStyleMessage, convoKey);
216             if (messagingStyleMessage.getTimestamp() > latestMessage.getTimestamp()) {
217                 latestMessage = messagingStyleMessage;
218             }
219         }
220         postNotification(convoKey, convoInfo, getChannelId(appDisplayName),
221                 getAvatarIcon(convoKey, latestMessage));
222     }
223 
initializeNewMessage(String deviceAddress, MessagingStyleMessage messagingStyleMessage, String notificationKey)224     private void initializeNewMessage(String deviceAddress,
225             MessagingStyleMessage messagingStyleMessage, String notificationKey) {
226         ConversationKey convoKey = new ConversationKey(deviceAddress, notificationKey);
227         if (!mNotificationInfos.containsKey(convoKey)) {
228             logw(TAG, "Conversation not found for notification: " + notificationKey);
229             return;
230         }
231 
232         if (!Utils.isValidMessagingStyleMessage(messagingStyleMessage)) {
233             logd(TAG, "Failed to initialize new Message, object missing required fields");
234             return;
235         }
236 
237         createNewMessage(deviceAddress, messagingStyleMessage, convoKey);
238         ConversationNotificationInfo convoInfo = mNotificationInfos.get(convoKey);
239 
240         postNotification(convoKey, convoInfo, getChannelId(convoInfo.getAppDisplayName()),
241                 getAvatarIcon(convoKey, messagingStyleMessage));
242     }
243 
244     @Nullable
getAvatarIcon(ConversationKey convoKey, MessagingStyleMessage message)245     private Bitmap getAvatarIcon(ConversationKey convoKey, MessagingStyleMessage message) {
246         ConversationNotificationInfo notificationInfo = mNotificationInfos.get(convoKey);
247         if (!notificationInfo.isGroupConvo()) {
248             return mOneOnOneConversationAvatarMap.get(
249                     SenderKey.createSenderKey(convoKey, message.getSender()));
250         } else if (message.getSender().getAvatar() != null
251                 || !message.getSender().getAvatar().isEmpty()) {
252             byte[] iconArray = message.getSender().getAvatar().toByteArray();
253             return BitmapFactory.decodeByteArray(iconArray, 0, iconArray.length);
254         }
255         return null;
256     }
257 
storeIcon(ConversationKey convoKey, AvatarIconSync iconSync)258     private void storeIcon(ConversationKey convoKey, AvatarIconSync iconSync) {
259         if (!Utils.isValidAvatarIconSync(iconSync) || !mNotificationInfos.containsKey(convoKey)) {
260             logw(TAG, "storeIcon: invalid AvatarIconSync obj or no conversation found.");
261             return;
262         }
263         if (mNotificationInfos.get(convoKey).isGroupConvo()) {
264             return;
265         }
266         byte[] iconArray = iconSync.getPerson().getAvatar().toByteArray();
267         Bitmap bitmap = BitmapFactory.decodeByteArray(iconArray, /* offset= */ 0, iconArray.length);
268         if (bitmap != null) {
269             mOneOnOneConversationAvatarMap.put(
270                     SenderKey.createSenderKey(convoKey, iconSync.getPerson()),
271                     bitmap);
272         } else {
273             logw(TAG, "storeIcon: Bitmap could not be created from byteArray");
274         }
275     }
276 
getChannelId(String appDisplayName)277     private String getChannelId(String appDisplayName) {
278         if (!mAppNameToChannel.containsKey(appDisplayName)) {
279             mAppNameToChannel.put(appDisplayName,
280                     new NotificationChannelWrapper(appDisplayName));
281         }
282         return mAppNameToChannel.get(appDisplayName).getChannelId(
283                 mProjectionStateListener.isProjectionInActiveForeground(
284                         mConnectedDeviceBluetoothAddress));
285     }
286 
createNewMessage(String deviceAddress, MessagingStyleMessage messagingStyleMessage, ConversationKey convoKey)287     private void createNewMessage(String deviceAddress, MessagingStyleMessage messagingStyleMessage,
288             ConversationKey convoKey) {
289         String appPackageName = mNotificationInfos.get(convoKey).getAppPackageName();
290         Message message = Message.parseFromMessage(deviceAddress, messagingStyleMessage,
291                 SenderKey.createSenderKey(convoKey, messagingStyleMessage.getSender()));
292         addMessageToNotificationInfo(message, convoKey);
293         AvatarIconSync iconSync = AvatarIconSync.newBuilder()
294                 .setPerson(messagingStyleMessage.getSender())
295                 .setMessagingAppPackageName(appPackageName)
296                 .build();
297         storeIcon(convoKey, iconSync);
298     }
299 
clearAppData(String deviceId, String packageName)300     private void clearAppData(String deviceId, String packageName) {
301         if (!packageName.equals(REMOVE_ALL_APP_DATA)) {
302             // Clearing data for specific package names is not supported since this use case
303             // is not needed right now.
304             logw(TAG, "clearAppData not supported for arg: " + packageName);
305             return;
306         }
307         cleanupMessagesAndNotifications(key -> key.matches(deviceId));
308         mOneOnOneConversationAvatarMap.entrySet().removeIf(
309                 conversationKey -> conversationKey.getKey().matches(deviceId));
310     }
311 
312     /** Creates notification channels per unique messaging application. **/
313     private class NotificationChannelWrapper {
314         private static final String SILENT_CHANNEL_NAME_SUFFIX = "-no-hun";
315         private final String mImportantChannelId;
316         private final String mSilentChannelId;
317 
NotificationChannelWrapper(String appDisplayName)318         NotificationChannelWrapper(String appDisplayName) {
319             mImportantChannelId = generateNotificationChannelId();
320             setupImportantNotificationChannel(mImportantChannelId, appDisplayName);
321             mSilentChannelId = generateNotificationChannelId();
322             setupSilentNotificationChannel(mSilentChannelId,
323                     appDisplayName + SILENT_CHANNEL_NAME_SUFFIX);
324         }
325 
326         /**
327          * Returns the channel id based on whether the notification should have a heads-up
328          * notification and an alert sound.
329          */
getChannelId(boolean showSilently)330         String getChannelId(boolean showSilently) {
331             if (showSilently) return mSilentChannelId;
332             return mImportantChannelId;
333         }
334 
setupImportantNotificationChannel(String channelId, String channelName)335         private void setupImportantNotificationChannel(String channelId, String channelName) {
336             NotificationChannel msgChannel = new NotificationChannel(channelId,
337                     channelName,
338                     NotificationManager.IMPORTANCE_HIGH);
339             msgChannel.setDescription(channelName);
340             msgChannel.setSound(Settings.System.DEFAULT_NOTIFICATION_URI, AUDIO_ATTRIBUTES);
341             mNotificationManager.createNotificationChannel(msgChannel);
342         }
343 
setupSilentNotificationChannel(String channelId, String channelName)344         private void setupSilentNotificationChannel(String channelId, String channelName) {
345             NotificationChannel msgChannel = new NotificationChannel(channelId,
346                     channelName,
347                     NotificationManager.IMPORTANCE_LOW);
348             mNotificationManager.createNotificationChannel(msgChannel);
349         }
350 
generateNotificationChannelId()351         private String generateNotificationChannelId() {
352             return NotificationMsgService.NOTIFICATION_MSG_CHANNEL_ID + "|"
353                     + NotificationChannelIdGenerator.generateChannelId();
354         }
355     }
356 
357     /** Helper class that generates unique IDs per Notification Channel. **/
358     static class NotificationChannelIdGenerator {
359         private static int NEXT_NOTIFICATION_CHANNEL_ID = 0;
360 
generateChannelId()361         static int generateChannelId() {
362             return ++NEXT_NOTIFICATION_CHANNEL_ID;
363         }
364     }
365 
366     @VisibleForTesting
setNotificationManager(NotificationManager manager)367     void setNotificationManager(NotificationManager manager) {
368         mNotificationManager = manager;
369     }
370 
371     @VisibleForTesting
setProjectionStateListener(ProjectionStateListener listener)372     void setProjectionStateListener(ProjectionStateListener listener) {
373         mProjectionStateListener = listener;
374     }
375 }
376