1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-06-14 17:10:16 +02:00
Gadgetbridge/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/NotificationsHandler.java
Daniele Gobbetti fa8f09e95a Garmin protocol: basic file transfer and notification handling
adds synchronization of supported files from watch to external directory
adds support for Activity and Monitoring files (workouts and activity samples), but those are not integrated yet
adds upload functionality (not used ATM and not tested)
adds notification support without actions
introduces centralized processing of "messageHandlers" (protobuf, file transfer, notifications)

also properly dispose of the music timer when disconnecting
2024-05-01 23:35:16 +01:00

335 lines
14 KiB
Java

package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import android.util.SparseArray;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Locale;
import java.util.Map;
import java.util.Queue;
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;
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;
public NotificationsHandler() {
this.notificationSpecQueue = new LinkedList<>();
this.upload = new Upload();
}
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!
return new NotificationUpdateMessage(notificationUpdateType, notificationSpec.type, getNotificationsCount(notificationSpec.type), notificationSpec.getId());
}
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 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);
}
}
return null;
}
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 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:
final MessageWriter messageWriter = new MessageWriter();
messageWriter.writeByte(NotificationCommand.GET_NOTIFICATION_ATTRIBUTES.code);
messageWriter.writeInt(((NotificationControlMessage) message).getNotificationId());
for (Map.Entry<NotificationAttribute, Integer> attribute : ((NotificationControlMessage) message).getNotificationAttributesMap().entrySet()) {
if (!attribute.getKey().equals(NotificationAttribute.MESSAGE_SIZE)) { //should be last
messageWriter.writeByte(attribute.getKey().code);
final byte[] bytes = attribute.getKey().getNotificationSpecAttribute(notificationSpec, attribute.getValue());
messageWriter.writeShort(bytes.length);
messageWriter.writeBytes(bytes);
LOG.info("ATTRIBUTE:{} value:{} length:{}", attribute.getKey(), new String(bytes), bytes.length);
}
}
if (((NotificationControlMessage) message).getNotificationAttributesMap().containsKey(NotificationAttribute.MESSAGE_SIZE)) {
messageWriter.writeByte(NotificationAttribute.MESSAGE_SIZE.code);
final byte[] bytes = NotificationAttribute.MESSAGE_SIZE.getNotificationSpecAttribute(notificationSpec, 0);
messageWriter.writeShort(bytes.length);
messageWriter.writeBytes(bytes);
LOG.info("ATTRIBUTE:{} value:{} length:{}", NotificationAttribute.MESSAGE_SIZE, new String(bytes), bytes.length);
}
NotificationFragment notificationFragment = new NotificationFragment(messageWriter.getBytes());
return upload.setCurrentlyUploading(notificationFragment);
default:
LOG.error("NOT SUPPORTED");
}
}
} else if (message instanceof NotificationDataStatusMessage) {
return upload.processUploadProgress((NotificationDataStatusMessage) message);
}
return null;
}
public void setEnabled(boolean enable) {
this.enabled = enable;
}
public enum NotificationCommand { //was AncsCommand
GET_NOTIFICATION_ATTRIBUTES(0),
GET_APP_ATTRIBUTES(1),
PERFORM_NOTIFICATION_ACTION(2),
// Garmin extensions
PERFORM_ANDROID_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 NotificationAttribute { //was AncsAttribute
APP_IDENTIFIER(0),
TITLE(1, true),
SUBTITLE(2, true),
MESSAGE(3, true),
MESSAGE_SIZE(4),
DATE(5),
// POSITIVE_ACTION_LABEL(6),
// NEGATIVE_ACTION_LABEL(7),
// 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:
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 = new String(new byte[]{0x00, 0x00, 0x00, 0x00});
break;
}
return toReturn.substring(0, Math.min(toReturn.length(), maxLength)).getBytes(StandardCharsets.UTF_8);
}
}
class Upload {
private NotificationFragment currentlyUploading;
public NotificationDataMessage setCurrentlyUploading(NotificationFragment currentlyUploading) {
this.currentlyUploading = currentlyUploading;
return currentlyUploading.take();
}
private GFDIMessage processUploadProgress(NotificationDataStatusMessage notificationDataStatusMessage) {
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;
}
}
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;
}
}
}