501 lines
22 KiB
Java
501 lines
22 KiB
Java
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
|
|
|
|
import android.util.SparseArray;
|
|
|
|
import org.apache.commons.lang3.EnumUtils;
|
|
import org.apache.commons.lang3.StringUtils;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import java.io.ByteArrayOutputStream;
|
|
import java.io.IOException;
|
|
import java.nio.ByteBuffer;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.text.SimpleDateFormat;
|
|
import java.util.ArrayList;
|
|
import java.util.Date;
|
|
import java.util.Iterator;
|
|
import java.util.LinkedList;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Map;
|
|
import java.util.Queue;
|
|
|
|
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl;
|
|
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl;
|
|
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
|
|
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
|
|
import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
|
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
|
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
|
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.NotificationControlMessage;
|
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.NotificationDataMessage;
|
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.NotificationUpdateMessage;
|
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.NotificationDataStatusMessage;
|
|
import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
|
|
|
|
public class NotificationsHandler implements MessageHandler {
|
|
public static final SimpleDateFormat NOTIFICATION_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.ROOT);
|
|
private static final Logger LOG = LoggerFactory.getLogger(NotificationsHandler.class);
|
|
private final Queue<NotificationSpec> notificationSpecQueue;
|
|
private final Upload upload;
|
|
private boolean enabled = false;
|
|
// Keep track of Notification ID -> action handle, as BangleJSDeviceSupport.
|
|
// TODO: This needs to be simplified.
|
|
private final LimitedQueue<Integer, Long> mNotificationReplyAction = new LimitedQueue<>(16);
|
|
|
|
|
|
public NotificationsHandler() {
|
|
this.notificationSpecQueue = new LinkedList<>();
|
|
this.upload = new Upload();
|
|
}
|
|
|
|
private static void encodeNotificationAttribute(NotificationSpec notificationSpec, Map.Entry<NotificationAttribute, Integer> entry, MessageWriter messageWriter) {
|
|
messageWriter.writeByte(entry.getKey().code);
|
|
final byte[] bytes = entry.getKey().getNotificationSpecAttribute(notificationSpec, entry.getValue());
|
|
messageWriter.writeShort(bytes.length);
|
|
messageWriter.writeBytes(bytes);
|
|
// LOG.info("ATTRIBUTE:{} value:{}/{} length:{}", entry.getKey(), new String(bytes), GB.hexdump(bytes), bytes.length);
|
|
}
|
|
|
|
|
|
private boolean addNotificationToQueue(NotificationSpec notificationSpec) {
|
|
boolean found = false;
|
|
Iterator<NotificationSpec> iterator = notificationSpecQueue.iterator();
|
|
while (iterator.hasNext()) {
|
|
NotificationSpec e = iterator.next();
|
|
if (e.getId() == notificationSpec.getId()) {
|
|
found = true;
|
|
iterator.remove();
|
|
}
|
|
}
|
|
notificationSpecQueue.offer(notificationSpec); // Add the notificationSpec to the front of the queue
|
|
return found;
|
|
}
|
|
|
|
public NotificationUpdateMessage onSetCallState(CallSpec callSpec) {
|
|
if (!enabled)
|
|
return null;
|
|
if (callSpec.command == CallSpec.CALL_INCOMING) {
|
|
NotificationSpec callNotificationSpec = new NotificationSpec(callSpec.number.hashCode());
|
|
callNotificationSpec.phoneNumber = callSpec.number;
|
|
callNotificationSpec.sourceAppId = callSpec.sourceAppId;
|
|
callNotificationSpec.title = StringUtils.isEmpty(callSpec.name) ? callSpec.number : callSpec.name;
|
|
callNotificationSpec.type = NotificationType.GENERIC_PHONE;
|
|
callNotificationSpec.body = StringUtils.isEmpty(callSpec.name) ? callSpec.number : callSpec.name;
|
|
|
|
return onNotification(callNotificationSpec);
|
|
} else {
|
|
if (callSpec.number != null) // this happens in debug screen
|
|
return onDeleteNotification(callSpec.number.hashCode());
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public NotificationUpdateMessage onNotification(NotificationSpec notificationSpec) {
|
|
if (!enabled)
|
|
return null;
|
|
final boolean isUpdate = addNotificationToQueue(notificationSpec);
|
|
|
|
NotificationUpdateMessage.NotificationUpdateType notificationUpdateType = isUpdate ? NotificationUpdateMessage.NotificationUpdateType.MODIFY : NotificationUpdateMessage.NotificationUpdateType.ADD;
|
|
|
|
if (notificationSpecQueue.size() > 10)
|
|
notificationSpecQueue.poll(); //remove the oldest notification TODO: should send a delete notification message to watch!
|
|
|
|
final boolean hasActions = (null != notificationSpec.attachedActions && !notificationSpec.attachedActions.isEmpty());
|
|
if (hasActions) {
|
|
for (int i = 0; i < notificationSpec.attachedActions.size(); i++) {
|
|
final NotificationSpec.Action action = notificationSpec.attachedActions.get(i);
|
|
|
|
if (action.type == NotificationSpec.Action.TYPE_WEARABLE_REPLY || action.type == NotificationSpec.Action.TYPE_SYNTECTIC_REPLY_PHONENR) {
|
|
mNotificationReplyAction.add(notificationSpec.getId(), action.handle);
|
|
}
|
|
}
|
|
}
|
|
return new NotificationUpdateMessage(notificationUpdateType, notificationSpec.type, getNotificationsCount(notificationSpec.type), notificationSpec.getId(), hasActions);
|
|
}
|
|
|
|
private int getNotificationsCount(NotificationType notificationType) {
|
|
int count = 0;
|
|
for (NotificationSpec e : notificationSpecQueue) {
|
|
count += e.type == notificationType ? 1 : 0;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private NotificationSpec getNotificationSpecFromQueue(int id) {
|
|
for (NotificationSpec e : notificationSpecQueue) {
|
|
if (e.getId() == id) {
|
|
return e;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public NotificationUpdateMessage onDeleteNotification(int id) {
|
|
if (!enabled)
|
|
return null;
|
|
|
|
Iterator<NotificationSpec> iterator = notificationSpecQueue.iterator();
|
|
while (iterator.hasNext()) {
|
|
NotificationSpec e = iterator.next();
|
|
if (e.getId() == id) {
|
|
iterator.remove();
|
|
return new NotificationUpdateMessage(NotificationUpdateMessage.NotificationUpdateType.REMOVE, e.type, getNotificationsCount(e.type), id, false);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public GFDIMessage handle(GFDIMessage message) {
|
|
if (!enabled)
|
|
return null;
|
|
if (message instanceof NotificationControlMessage) {
|
|
final NotificationSpec notificationSpec = getNotificationSpecFromQueue(((NotificationControlMessage) message).getNotificationId());
|
|
if (notificationSpec != null) {
|
|
switch (((NotificationControlMessage) message).getCommand()) {
|
|
case GET_NOTIFICATION_ATTRIBUTES:
|
|
return getNotificationDataMessage((NotificationControlMessage) message, notificationSpec);
|
|
case PERFORM_LEGACY_NOTIFICATION_ACTION:
|
|
LOG.info("Legacy Notification: {}", ((NotificationControlMessage) message).getLegacyNotificationAction());
|
|
break;
|
|
case PERFORM_NOTIFICATION_ACTION:
|
|
performNotificationAction((NotificationControlMessage) message, notificationSpec);
|
|
break;
|
|
|
|
default:
|
|
LOG.error("NOT SUPPORTED: {}", ((NotificationControlMessage) message).getCommand());
|
|
}
|
|
}
|
|
} else if (message instanceof NotificationDataStatusMessage) {
|
|
return upload.processUploadProgress((NotificationDataStatusMessage) message);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private void performNotificationAction(NotificationControlMessage message, NotificationSpec notificationSpec) {
|
|
final GBDeviceEventNotificationControl deviceEvtNotificationControl = new GBDeviceEventNotificationControl();
|
|
deviceEvtNotificationControl.handle = notificationSpec.getId();
|
|
final GBDeviceEventCallControl deviceEvtCallControl = new GBDeviceEventCallControl();
|
|
switch (message.getNotificationAction()) {
|
|
case REPLY_INCOMING_CALL:
|
|
case REPLY_MESSAGES:
|
|
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.REPLY;
|
|
deviceEvtNotificationControl.reply = message.getActionString();
|
|
if (notificationSpec.type.equals(NotificationType.GENERIC_PHONE) || notificationSpec.type.equals(NotificationType.GENERIC_SMS)) {
|
|
deviceEvtNotificationControl.phoneNumber = notificationSpec.phoneNumber;
|
|
} else {
|
|
deviceEvtNotificationControl.handle = mNotificationReplyAction.lookup(notificationSpec.getId()); //handle of wearable action is needed
|
|
}
|
|
message.setDeviceEvent(deviceEvtNotificationControl);
|
|
break;
|
|
case ACCEPT_INCOMING_CALL:
|
|
deviceEvtCallControl.event = GBDeviceEventCallControl.Event.ACCEPT;
|
|
message.setDeviceEvent(deviceEvtCallControl);
|
|
break;
|
|
case REJECT_INCOMING_CALL:
|
|
deviceEvtCallControl.event = GBDeviceEventCallControl.Event.REJECT;
|
|
message.setDeviceEvent(deviceEvtCallControl);
|
|
break;
|
|
case DISMISS_NOTIFICATION:
|
|
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.DISMISS;
|
|
message.setDeviceEvent(deviceEvtNotificationControl);
|
|
break;
|
|
case BLOCK_APPLICATION:
|
|
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.MUTE;
|
|
message.setDeviceEvent(deviceEvtNotificationControl);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private NotificationDataMessage getNotificationDataMessage(NotificationControlMessage message, NotificationSpec notificationSpec) {
|
|
final MessageWriter messageWriter = new MessageWriter();
|
|
messageWriter.writeByte(NotificationCommand.GET_NOTIFICATION_ATTRIBUTES.code);
|
|
messageWriter.writeInt(message.getNotificationId());
|
|
Map.Entry<NotificationAttribute, Integer> lastEntry = null;
|
|
for (Map.Entry<NotificationAttribute, Integer> entry : message.getNotificationAttributesMap().entrySet()) {
|
|
if (!NotificationAttribute.MESSAGE_SIZE.equals(entry.getKey())) {
|
|
encodeNotificationAttribute(notificationSpec, entry, messageWriter);
|
|
} else {
|
|
lastEntry = entry;
|
|
}
|
|
}
|
|
if (lastEntry != null) {
|
|
encodeNotificationAttribute(notificationSpec, lastEntry, messageWriter);
|
|
}
|
|
NotificationFragment notificationFragment = new NotificationFragment(messageWriter.getBytes());
|
|
return upload.setCurrentlyUploading(notificationFragment);
|
|
}
|
|
|
|
|
|
public void setEnabled(boolean enable) {
|
|
this.enabled = enable;
|
|
}
|
|
|
|
public enum NotificationCommand { //was AncsCommand
|
|
GET_NOTIFICATION_ATTRIBUTES(0),
|
|
GET_APP_ATTRIBUTES(1), //unknown/untested
|
|
PERFORM_LEGACY_NOTIFICATION_ACTION(2),
|
|
PERFORM_NOTIFICATION_ACTION(128);
|
|
|
|
public final int code;
|
|
|
|
NotificationCommand(int code) {
|
|
this.code = code;
|
|
}
|
|
|
|
public static NotificationCommand fromCode(int code) {
|
|
for (NotificationCommand value : values()) {
|
|
if (value.code == code)
|
|
return value;
|
|
}
|
|
throw new IllegalArgumentException("Unknown notification command " + code);
|
|
}
|
|
}
|
|
|
|
public enum LegacyNotificationAction { //was AncsAction
|
|
ACCEPT,
|
|
REFUSE
|
|
|
|
}
|
|
public enum NotificationAttribute { //was AncsAttribute
|
|
APP_IDENTIFIER(0),
|
|
TITLE(1, true),
|
|
SUBTITLE(2, true),
|
|
MESSAGE(3, true),
|
|
MESSAGE_SIZE(4),
|
|
DATE(5),
|
|
// POSITIVE_ACTION_LABEL(6), //needed only for legacy notification actions
|
|
NEGATIVE_ACTION_LABEL(7), //needed only for legacy notification actions
|
|
// Garmin extensions
|
|
// PHONE_NUMBER(126, true),
|
|
ACTIONS(127, false, true),
|
|
;
|
|
private static final SparseArray<NotificationAttribute> valueByCode;
|
|
|
|
static {
|
|
final NotificationAttribute[] values = values();
|
|
valueByCode = new SparseArray<>(values.length);
|
|
for (NotificationAttribute value : values) {
|
|
valueByCode.append(value.code, value);
|
|
}
|
|
}
|
|
|
|
public final int code;
|
|
public final boolean hasLengthParam;
|
|
public final boolean hasAdditionalParams;
|
|
|
|
NotificationAttribute(int code) {
|
|
this(code, false, false);
|
|
}
|
|
|
|
NotificationAttribute(int code, boolean hasLengthParam) {
|
|
this(code, hasLengthParam, false);
|
|
}
|
|
|
|
NotificationAttribute(int code, boolean hasLengthParam, boolean hasAdditionalParams) {
|
|
this.code = code;
|
|
this.hasLengthParam = hasLengthParam;
|
|
this.hasAdditionalParams = hasAdditionalParams;
|
|
}
|
|
|
|
public static NotificationAttribute getByCode(int code) {
|
|
return valueByCode.get(code);
|
|
}
|
|
|
|
public byte[] getNotificationSpecAttribute(NotificationSpec notificationSpec, int maxLength) {
|
|
String toReturn = "";
|
|
switch (this) {
|
|
case DATE:
|
|
final long notificationTimestamp = notificationSpec.when == 0 ? System.currentTimeMillis() : notificationSpec.when;
|
|
toReturn = NOTIFICATION_DATE_FORMAT.format(new Date(notificationTimestamp));
|
|
break;
|
|
case TITLE:
|
|
if (NotificationType.GENERIC_SMS.equals(notificationSpec.type))
|
|
toReturn = notificationSpec.sender == null ? "" : notificationSpec.sender;
|
|
else
|
|
toReturn = notificationSpec.title == null ? "" : notificationSpec.title;
|
|
break;
|
|
case SUBTITLE:
|
|
toReturn = notificationSpec.subject == null ? "" : notificationSpec.subject;
|
|
break;
|
|
case APP_IDENTIFIER:
|
|
toReturn = notificationSpec.sourceAppId == null ? "" : notificationSpec.sourceAppId;
|
|
break;
|
|
case MESSAGE:
|
|
toReturn = notificationSpec.body == null ? "" : notificationSpec.body;
|
|
break;
|
|
case MESSAGE_SIZE:
|
|
toReturn = Integer.toString(notificationSpec.body == null ? "".length() : notificationSpec.body.length());
|
|
break;
|
|
case ACTIONS:
|
|
toReturn = encodeNotificationActionsString(notificationSpec);
|
|
break;
|
|
}
|
|
if (maxLength == 0)
|
|
return toReturn.getBytes(StandardCharsets.UTF_8);
|
|
return toReturn.substring(0, Math.min(toReturn.length(), maxLength)).getBytes(StandardCharsets.UTF_8);
|
|
}
|
|
|
|
private String encodeNotificationActionsString(NotificationSpec notificationSpec) {
|
|
|
|
final List<byte[]> garminActions = new ArrayList<>();
|
|
if (notificationSpec.type.equals(NotificationType.GENERIC_PHONE)) {
|
|
garminActions.add(encodeNotificationAction(NotificationAction.REPLY_INCOMING_CALL, " ")); //text is not shown on watch
|
|
garminActions.add(encodeNotificationAction(NotificationAction.REJECT_INCOMING_CALL, " ")); //text is not shown on watch
|
|
garminActions.add(encodeNotificationAction(NotificationAction.ACCEPT_INCOMING_CALL, " ")); //text is not shown on watch
|
|
}
|
|
if (null != notificationSpec.attachedActions) {
|
|
for (NotificationSpec.Action action : notificationSpec.attachedActions) {
|
|
switch (action.type) {
|
|
case NotificationSpec.Action.TYPE_WEARABLE_REPLY:
|
|
case NotificationSpec.Action.TYPE_SYNTECTIC_REPLY_PHONENR:
|
|
garminActions.add(encodeNotificationAction(NotificationAction.REPLY_MESSAGES, action.title));
|
|
break;
|
|
case NotificationSpec.Action.TYPE_SYNTECTIC_DISMISS:
|
|
garminActions.add(encodeNotificationAction(NotificationAction.DISMISS_NOTIFICATION, action.title));
|
|
break;
|
|
case NotificationSpec.Action.TYPE_SYNTECTIC_MUTE:
|
|
garminActions.add(encodeNotificationAction(NotificationAction.BLOCK_APPLICATION, action.title));
|
|
break;
|
|
|
|
}
|
|
// LOG.info("Notification has action {} with title {}", action.type, action.title);
|
|
}
|
|
}
|
|
if (garminActions.isEmpty())
|
|
return new String(new byte[]{0x00, 0x00, 0x00, 0x00});
|
|
|
|
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
|
|
byteArrayOutputStream.write(garminActions.size());
|
|
for (byte[] item : garminActions) {
|
|
byteArrayOutputStream.write(item);
|
|
}
|
|
return byteArrayOutputStream.toString();
|
|
} catch (IOException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
|
|
}
|
|
|
|
private byte[] encodeNotificationAction(NotificationAction notificationAction, String description) {
|
|
final ByteBuffer action = ByteBuffer.allocate(3 + description.getBytes(StandardCharsets.UTF_8).length);
|
|
action.put((byte) notificationAction.code);
|
|
if (null == notificationAction.notificationActionIconPosition)
|
|
action.put((byte) 0x00);
|
|
else
|
|
action.put((byte) EnumUtils.generateBitVector(NotificationActionIconPosition.class, notificationAction.notificationActionIconPosition));
|
|
action.put((byte) description.getBytes(StandardCharsets.UTF_8).length);
|
|
action.put(description.getBytes());
|
|
return action.array();
|
|
}
|
|
}
|
|
|
|
public enum NotificationAction {
|
|
REPLY_INCOMING_CALL(94, NotificationActionIconPosition.BOTTOM),
|
|
REPLY_MESSAGES(95, NotificationActionIconPosition.BOTTOM),
|
|
ACCEPT_INCOMING_CALL(96, NotificationActionIconPosition.RIGHT),
|
|
REJECT_INCOMING_CALL(97, NotificationActionIconPosition.LEFT),
|
|
DISMISS_NOTIFICATION(98, NotificationActionIconPosition.LEFT),
|
|
BLOCK_APPLICATION(99, null),
|
|
;
|
|
|
|
private final int code;
|
|
private final NotificationActionIconPosition notificationActionIconPosition;
|
|
|
|
NotificationAction(int code, NotificationActionIconPosition notificationActionIconPosition) {
|
|
this.code = code;
|
|
this.notificationActionIconPosition = notificationActionIconPosition;
|
|
}
|
|
|
|
public static NotificationAction fromCode(final int code) {
|
|
for (final NotificationAction notificationAction : NotificationAction.values()) {
|
|
if (notificationAction.code == code) {
|
|
return notificationAction;
|
|
}
|
|
}
|
|
throw new IllegalArgumentException("Unknown notification action code " + code);
|
|
}
|
|
|
|
}
|
|
|
|
enum NotificationActionIconPosition { //educated guesses based on the icons' positions on vĂvomove style
|
|
BOTTOM, //or is it reply?
|
|
RIGHT, //or is it accept?
|
|
LEFT, //or is it dismiss/refuse?
|
|
}
|
|
public static class Upload {
|
|
|
|
private NotificationFragment currentlyUploading;
|
|
|
|
public NotificationDataMessage setCurrentlyUploading(NotificationFragment currentlyUploading) {
|
|
this.currentlyUploading = currentlyUploading;
|
|
return currentlyUploading.take();
|
|
}
|
|
|
|
private GFDIMessage processUploadProgress(NotificationDataStatusMessage notificationDataStatusMessage) {
|
|
if (null == currentlyUploading) {
|
|
LOG.warn("Received Upload Progress but we are not sending any notification");
|
|
return null;
|
|
}
|
|
if (!currentlyUploading.dataHolder.hasRemaining()) {
|
|
this.currentlyUploading = null;
|
|
LOG.info("SENT ALL");
|
|
|
|
return new NotificationDataStatusMessage(GFDIMessage.GarminMessage.NOTIFICATION_DATA, GFDIMessage.Status.ACK, NotificationDataStatusMessage.TransferStatus.OK);
|
|
} else {
|
|
if (notificationDataStatusMessage.canProceed()) {
|
|
LOG.info("SENDING NEXT CHUNK!!!");
|
|
return currentlyUploading.take();
|
|
} else {
|
|
LOG.warn("Cannot proceed with upload"); //TODO: send the correct status message
|
|
this.currentlyUploading = null;
|
|
}
|
|
|
|
}
|
|
return null;
|
|
}
|
|
|
|
}
|
|
|
|
public static class NotificationFragment {
|
|
private final int dataSize;
|
|
private final ByteBuffer dataHolder;
|
|
private final int maxBlockSize = 300;
|
|
private int runningCrc;
|
|
|
|
NotificationFragment(byte[] contents) {
|
|
this.dataHolder = ByteBuffer.wrap(contents);
|
|
this.dataSize = contents.length;
|
|
this.dataHolder.flip();
|
|
this.dataHolder.compact();
|
|
this.setRunningCrc(0);
|
|
}
|
|
|
|
public int getDataSize() {
|
|
return dataSize;
|
|
}
|
|
|
|
private int getMaxBlockSize() {
|
|
return maxBlockSize;
|
|
}
|
|
|
|
private NotificationDataMessage take() {
|
|
final int currentOffset = this.dataHolder.position();
|
|
final byte[] chunk = new byte[Math.min(this.dataHolder.remaining(), getMaxBlockSize())];
|
|
this.dataHolder.get(chunk);
|
|
setRunningCrc(ChecksumCalculator.computeCrc(getRunningCrc(), chunk, 0, chunk.length));
|
|
return new NotificationDataMessage(chunk, getDataSize(), currentOffset, getRunningCrc());
|
|
}
|
|
|
|
private int getRunningCrc() {
|
|
return runningCrc;
|
|
}
|
|
|
|
private void setRunningCrc(int runningCrc) {
|
|
this.runningCrc = runningCrc;
|
|
}
|
|
}
|
|
}
|