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