blob: e2c3ab74b8f7d2f51f30aa2a9c126e57f5b92492 [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.car.companiondevicesupport.feature.notificationmsg;
import static com.android.car.connecteddevice.util.SafeLog.logd;
import static com.android.car.connecteddevice.util.SafeLog.logw;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.AudioAttributes;
import android.provider.Settings;
import androidx.annotation.Nullable;
import com.android.car.companiondevicesupport.api.external.CompanionDevice;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.Action;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.AvatarIconSync;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.CarToPhoneMessage;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.ClearAppDataRequest;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.ConversationNotification;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MapEntry;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MessagingStyleMessage;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.PhoneToCarMessage;
import com.android.car.messenger.common.BaseNotificationDelegate;
import com.android.car.messenger.common.ConversationKey;
import com.android.car.messenger.common.ConversationNotificationInfo;
import com.android.car.messenger.common.Message;
import com.android.car.messenger.common.ProjectionStateListener;
import com.android.car.messenger.common.SenderKey;
import com.android.car.messenger.common.Utils;
import com.android.internal.annotations.VisibleForTesting;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Posts Message notifications sent from the {@link CompanionDevice}, and relays user interaction
* with the messages back to the device.
**/
public class NotificationMsgDelegate extends BaseNotificationDelegate {
private static final String TAG = "NotificationMsgDelegate";
/** Key for the Reply string in a {@link MapEntry}. **/
private static final String REPLY_KEY = "REPLY";
/**
* Value for {@link ClearAppDataRequest#getMessagingAppPackageName()}, representing
* when all messaging applications' data should be removed.
*/
private static final String REMOVE_ALL_APP_DATA = "ALL";
private static final AudioAttributes AUDIO_ATTRIBUTES = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.build();
private Map<String, NotificationChannelWrapper> mAppNameToChannel = new HashMap<>();
/**
* The Bluetooth Device address of the connected device. NOTE: this is NOT the same as
* {@link CompanionDevice#getDeviceId()}.
*/
private String mConnectedDeviceBluetoothAddress;
/**
* Maps a Bitmap of a sender's Large Icon to the sender's unique key for 1-1 conversations.
**/
protected final Map<SenderKey, Bitmap> mOneOnOneConversationAvatarMap = new HashMap<>();
/** Tracks whether a projection application is active in the foreground. **/
private final ProjectionStateListener mProjectionStateListener;
public NotificationMsgDelegate(Context context) {
super(context, /* useLetterTile */ false);
mProjectionStateListener = new ProjectionStateListener(context);
}
public void onMessageReceived(CompanionDevice device, PhoneToCarMessage message) {
String notificationKey = message.getNotificationKey();
switch (message.getMessageDataCase()) {
case CONVERSATION:
initializeNewConversation(device, message.getConversation(), notificationKey);
return;
case MESSAGE:
initializeNewMessage(device.getDeviceId(), message.getMessage(), notificationKey);
return;
case STATUS_UPDATE:
// TODO (b/144924164): implement Action Request tracking logic.
return;
case AVATAR_ICON_SYNC:
storeIcon(new ConversationKey(device.getDeviceId(), notificationKey),
message.getAvatarIconSync());
return;
case PHONE_METADATA:
mConnectedDeviceBluetoothAddress =
message.getPhoneMetadata().getBluetoothDeviceAddress();
return;
case CLEAR_APP_DATA_REQUEST:
clearAppData(device.getDeviceId(),
message.getClearAppDataRequest().getMessagingAppPackageName());
return;
case FEATURE_ENABLED_STATE_CHANGE:
// TODO(b/150326327): implement enabled state change behavior.
return;
case MESSAGEDATA_NOT_SET:
default:
logw(TAG, "PhoneToCarMessage: message data not set!");
}
}
protected CarToPhoneMessage dismiss(ConversationKey convoKey) {
super.dismissInternal(convoKey);
// TODO(b/144924164): add a request id to the action.
Action action = Action.newBuilder()
.setActionName(Action.ActionName.DISMISS)
.setNotificationKey(convoKey.getSubKey())
.build();
return CarToPhoneMessage.newBuilder()
.setNotificationKey(convoKey.getSubKey())
.setActionRequest(action)
.build();
}
protected CarToPhoneMessage markAsRead(ConversationKey convoKey) {
excludeFromNotification(convoKey);
// TODO(b/144924164): add a request id to the action.
Action action = Action.newBuilder()
.setActionName(Action.ActionName.MARK_AS_READ)
.setNotificationKey(convoKey.getSubKey())
.build();
return CarToPhoneMessage.newBuilder()
.setNotificationKey(convoKey.getSubKey())
.setActionRequest(action)
.build();
}
protected CarToPhoneMessage reply(ConversationKey convoKey, String message) {
// TODO(b/144924164): add a request id to the action.
MapEntry entry = MapEntry.newBuilder()
.setKey(REPLY_KEY)
.setValue(message)
.build();
Action action = Action.newBuilder()
.setActionName(Action.ActionName.REPLY)
.setNotificationKey(convoKey.getSubKey())
.addMapEntry(entry)
.build();
return CarToPhoneMessage.newBuilder()
.setNotificationKey(convoKey.getSubKey())
.setActionRequest(action)
.build();
}
protected void onDestroy() {
// Erase all the notifications and local data, so that no user data stays on the device
// after the feature is stopped.
cleanupMessagesAndNotifications(key -> true);
mProjectionStateListener.destroy();
mOneOnOneConversationAvatarMap.clear();
mAppNameToChannel.clear();
mConnectedDeviceBluetoothAddress = null;
}
protected void onDeviceDisconnected(String deviceId) {
mConnectedDeviceBluetoothAddress = null;
cleanupMessagesAndNotifications(key -> key.matches(deviceId));
mOneOnOneConversationAvatarMap.entrySet().removeIf(
conversationKey -> conversationKey.getKey().matches(deviceId));
}
private void initializeNewConversation(CompanionDevice device,
ConversationNotification notification, String notificationKey) {
String deviceAddress = device.getDeviceId();
ConversationKey convoKey = new ConversationKey(deviceAddress, notificationKey);
if (!Utils.isValidConversationNotification(notification, /* isShallowCheck= */ false)) {
logd(TAG, "Failed to initialize new Conversation, object missing required fields");
return;
}
ConversationNotificationInfo convoInfo;
if (mNotificationInfos.containsKey(convoKey)) {
logw(TAG, "Conversation already exists! " + notificationKey);
convoInfo = mNotificationInfos.get(convoKey);
} else {
convoInfo = ConversationNotificationInfo.
createConversationNotificationInfo(device.getDeviceName(), device.getDeviceId(),
notification, notificationKey);
mNotificationInfos.put(convoKey, convoInfo);
}
String appDisplayName = convoInfo.getAppDisplayName();
List<MessagingStyleMessage> messages =
notification.getMessagingStyle().getMessagingStyleMsgList();
MessagingStyleMessage latestMessage = messages.get(0);
for (MessagingStyleMessage messagingStyleMessage : messages) {
createNewMessage(deviceAddress, messagingStyleMessage, convoKey);
if (messagingStyleMessage.getTimestamp() > latestMessage.getTimestamp()) {
latestMessage = messagingStyleMessage;
}
}
postNotification(convoKey, convoInfo, getChannelId(appDisplayName),
getAvatarIcon(convoKey, latestMessage));
}
private void initializeNewMessage(String deviceAddress,
MessagingStyleMessage messagingStyleMessage, String notificationKey) {
ConversationKey convoKey = new ConversationKey(deviceAddress, notificationKey);
if (!mNotificationInfos.containsKey(convoKey)) {
logw(TAG, "Conversation not found for notification: " + notificationKey);
return;
}
if (!Utils.isValidMessagingStyleMessage(messagingStyleMessage)) {
logd(TAG, "Failed to initialize new Message, object missing required fields");
return;
}
createNewMessage(deviceAddress, messagingStyleMessage, convoKey);
ConversationNotificationInfo convoInfo = mNotificationInfos.get(convoKey);
postNotification(convoKey, convoInfo, getChannelId(convoInfo.getAppDisplayName()),
getAvatarIcon(convoKey, messagingStyleMessage));
}
@Nullable
private Bitmap getAvatarIcon(ConversationKey convoKey, MessagingStyleMessage message) {
ConversationNotificationInfo notificationInfo = mNotificationInfos.get(convoKey);
if (!notificationInfo.isGroupConvo()) {
return mOneOnOneConversationAvatarMap.get(
SenderKey.createSenderKey(convoKey, message.getSender()));
} else if (message.getSender().getAvatar() != null
|| !message.getSender().getAvatar().isEmpty()) {
byte[] iconArray = message.getSender().getAvatar().toByteArray();
return BitmapFactory.decodeByteArray(iconArray, 0, iconArray.length);
}
return null;
}
private void storeIcon(ConversationKey convoKey, AvatarIconSync iconSync) {
if (!Utils.isValidAvatarIconSync(iconSync) || !mNotificationInfos.containsKey(convoKey)) {
logw(TAG, "storeIcon: invalid AvatarIconSync obj or no conversation found.");
return;
}
if (mNotificationInfos.get(convoKey).isGroupConvo()) {
return;
}
byte[] iconArray = iconSync.getPerson().getAvatar().toByteArray();
Bitmap bitmap = BitmapFactory.decodeByteArray(iconArray, /* offset= */ 0, iconArray.length);
if (bitmap != null) {
mOneOnOneConversationAvatarMap.put(
SenderKey.createSenderKey(convoKey, iconSync.getPerson()),
bitmap);
} else {
logw(TAG, "storeIcon: Bitmap could not be created from byteArray");
}
}
private String getChannelId(String appDisplayName) {
if (!mAppNameToChannel.containsKey(appDisplayName)) {
mAppNameToChannel.put(appDisplayName,
new NotificationChannelWrapper(appDisplayName));
}
boolean isProjectionActive = mProjectionStateListener.isProjectionInActiveForeground(
mConnectedDeviceBluetoothAddress);
return mAppNameToChannel.get(appDisplayName).getChannelId(isProjectionActive);
}
private void createNewMessage(String deviceAddress, MessagingStyleMessage messagingStyleMessage,
ConversationKey convoKey) {
String appPackageName = mNotificationInfos.get(convoKey).getAppPackageName();
Message message = Message.parseFromMessage(deviceAddress, messagingStyleMessage,
SenderKey.createSenderKey(convoKey, messagingStyleMessage.getSender()));
addMessageToNotificationInfo(message, convoKey);
AvatarIconSync iconSync = AvatarIconSync.newBuilder()
.setPerson(messagingStyleMessage.getSender())
.setMessagingAppPackageName(appPackageName)
.build();
storeIcon(convoKey, iconSync);
}
private void clearAppData(String deviceId, String packageName) {
if (!packageName.equals(REMOVE_ALL_APP_DATA)) {
// Clearing data for specific package names is not supported since this use case
// is not needed right now.
logw(TAG, "clearAppData not supported for arg: " + packageName);
return;
}
cleanupMessagesAndNotifications(key -> key.matches(deviceId));
mOneOnOneConversationAvatarMap.entrySet().removeIf(
conversationKey -> conversationKey.getKey().matches(deviceId));
}
/** Creates notification channels per unique messaging application. **/
private class NotificationChannelWrapper {
private static final String SILENT_CHANNEL_NAME_SUFFIX = "-no-hun";
private final String mImportantChannelId;
private final String mSilentChannelId;
NotificationChannelWrapper(String appDisplayName) {
mImportantChannelId = generateNotificationChannelId();
setupImportantNotificationChannel(mImportantChannelId, appDisplayName);
mSilentChannelId = generateNotificationChannelId();
setupSilentNotificationChannel(mSilentChannelId,
appDisplayName + SILENT_CHANNEL_NAME_SUFFIX);
}
/**
* Returns the channel id based on whether the notification should have a heads-up
* notification and an alert sound.
*/
String getChannelId(boolean showSilently) {
if (showSilently) return mSilentChannelId;
return mImportantChannelId;
}
private void setupImportantNotificationChannel(String channelId, String channelName) {
NotificationChannel msgChannel = new NotificationChannel(channelId,
channelName,
NotificationManager.IMPORTANCE_HIGH);
msgChannel.setDescription(channelName);
msgChannel.setSound(Settings.System.DEFAULT_NOTIFICATION_URI, AUDIO_ATTRIBUTES);
mNotificationManager.createNotificationChannel(msgChannel);
}
private void setupSilentNotificationChannel(String channelId, String channelName) {
NotificationChannel msgChannel = new NotificationChannel(channelId,
channelName,
NotificationManager.IMPORTANCE_LOW);
mNotificationManager.createNotificationChannel(msgChannel);
}
private String generateNotificationChannelId() {
return NotificationMsgService.NOTIFICATION_MSG_CHANNEL_ID + "|"
+ NotificationChannelIdGenerator.generateChannelId();
}
}
/** Helper class that generates unique IDs per Notification Channel. **/
static class NotificationChannelIdGenerator {
private static int NEXT_NOTIFICATION_CHANNEL_ID = 0;
static int generateChannelId() {
return ++NEXT_NOTIFICATION_CHANNEL_ID;
}
}
@VisibleForTesting
void setNotificationManager(NotificationManager manager) {
mNotificationManager = manager;
}
}