diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
index 8c357d15a..bde2a7405 100644
--- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
+++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
@@ -92,6 +92,7 @@ public class GBDaoGenerator {
addPineTimeActivitySample(schema, user, device);
addHybridHRActivitySample(schema, user, device);
addVivomoveHrActivitySample(schema, user, device);
+ addDownloadedFitFile(schema, user, device);
addCalendarSyncState(schema, device);
addAlarms(schema, user, device);
@@ -475,6 +476,35 @@ public class GBDaoGenerator {
return activitySample;
}
+ private static Entity addDownloadedFitFile(Schema schema, Entity user, Entity device) {
+ final Entity downloadedFitFile = addEntity(schema, "DownloadedFitFile");
+ downloadedFitFile.implementsSerializable();
+ downloadedFitFile.setJavaDoc("This class represents a single FIT file downloaded from a FIT-compatible device.");
+ downloadedFitFile.addIdProperty().autoincrement();
+ downloadedFitFile.addLongProperty("downloadTimestamp").notNull();
+ final Property deviceId = downloadedFitFile.addLongProperty("deviceId").notNull().getProperty();
+ downloadedFitFile.addToOne(device, deviceId);
+ final Property userId = downloadedFitFile.addLongProperty("userId").notNull().getProperty();
+ downloadedFitFile.addToOne(user, userId);
+ final Property fileNumber = downloadedFitFile.addIntProperty("fileNumber").notNull().getProperty();
+ downloadedFitFile.addIntProperty("fileDataType").notNull();
+ downloadedFitFile.addIntProperty("fileSubType").notNull();
+ downloadedFitFile.addLongProperty("fileTimestamp").notNull();
+ downloadedFitFile.addIntProperty("specificFlags").notNull();
+ downloadedFitFile.addIntProperty("fileSize").notNull();
+ downloadedFitFile.addByteArrayProperty("fileData");
+
+ final Index indexUnique = new Index();
+ indexUnique.addProperty(deviceId);
+ indexUnique.addProperty(userId);
+ indexUnique.addProperty(fileNumber);
+ indexUnique.makeUnique();
+
+ downloadedFitFile.addIndex(indexUnique);
+
+ return downloadedFitFile;
+ }
+
private static Entity addWatchXPlusHealthActivitySample(Schema schema, Entity user, Entity device) {
Entity activitySample = addEntity(schema, "WatchXPlusActivitySample");
activitySample.implementsSerializable();
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/VivomoveHrCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/VivomoveHrCoordinator.java
index d52b5612a..211d67c35 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/VivomoveHrCoordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/VivomoveHrCoordinator.java
@@ -63,7 +63,7 @@ public class VivomoveHrCoordinator extends AbstractDeviceCoordinator {
}
@Override
- public int getAlarmSlotCount() {
+ public int getAlarmSlotCount(GBDevice device) {
return 0;
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java
index 779b77043..6760869c9 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java
@@ -81,6 +81,17 @@ import nodomain.freeyourgadget.gadgetbridge.util.MediaManager;
import nodomain.freeyourgadget.gadgetbridge.util.NotificationUtils;
import nodomain.freeyourgadget.gadgetbridge.util.PebbleUtils;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
import static nodomain.freeyourgadget.gadgetbridge.activities.NotificationFilterActivity.NOTIFICATION_FILTER_MODE_BLACKLIST;
import static nodomain.freeyourgadget.gadgetbridge.activities.NotificationFilterActivity.NOTIFICATION_FILTER_MODE_WHITELIST;
@@ -333,6 +344,7 @@ public class NotificationListener extends NotificationListenerService {
}
NotificationSpec notificationSpec = new NotificationSpec();
+ notificationSpec.when = notification.when;
// determinate Source App Name ("Label")
String name = getAppName(source);
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationSpec.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationSpec.java
index 6bfcf55dc..b7c1e3a84 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationSpec.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationSpec.java
@@ -24,6 +24,7 @@ public class NotificationSpec {
public int flags;
private static final AtomicInteger c = new AtomicInteger((int) (System.currentTimeMillis()/1000));
private int id;
+ public long when;
public String sender;
public String phoneNumber;
public String title;
@@ -53,7 +54,7 @@ public class NotificationSpec {
public int dndSuppressed;
public NotificationSpec() {
- this.id = c.incrementAndGet();
+ this(-1);
}
public NotificationSpec(int id) {
@@ -61,6 +62,7 @@ public class NotificationSpec {
this.id = id;
else
this.id = c.incrementAndGet();
+ this.when = System.currentTimeMillis();
}
public int getId() {
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java
index 60f554068..d23138ace 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java
@@ -25,25 +25,20 @@ package nodomain.freeyourgadget.gadgetbridge.service;
import android.bluetooth.BluetoothAdapter;
import android.content.Context;
import android.widget.Toast;
-
-import java.lang.reflect.Constructor;
-import java.util.EnumSet;
-
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.asteroidos.AsteroidOSDeviceSupport;
-import nodomain.freeyourgadget.gadgetbridge.service.devices.binary_sensor.BinarySensorSupport;
-import nodomain.freeyourgadget.gadgetbridge.service.devices.fitpro.FitProDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs.BangleJSDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.binary_sensor.BinarySensorSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.CasioGB6900DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.CasioGBX100DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.domyos.DomyosT540Support;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.fitpro.FitProDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.flipper.zero.support.FlipperZeroSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.galaxy_buds.GalaxyBudsDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.hplus.HPlusSupport;
-import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitband5.AmazfitBand5Support;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitband7.AmazfitBand7Support;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbip.AmazfitBipLiteSupport;
@@ -112,12 +107,15 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.tlw64.TLW64Support;
import nodomain.freeyourgadget.gadgetbridge.service.devices.um25.Support.UM25Support;
import nodomain.freeyourgadget.gadgetbridge.service.devices.vesc.VescDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.vibratissimo.VibratissimoSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.VivomoveHrSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.waspos.WaspOSDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.watch9.Watch9DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.xwatch.XWatchSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.zetime.ZeTimeDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import java.lang.reflect.Constructor;
+
public class DeviceSupportFactory {
private final BluetoothAdapter mBtAdapter;
private final Context mContext;
@@ -372,6 +370,8 @@ public class DeviceSupportFactory {
return new ServiceDeviceSupport(new AsteroidOSDeviceSupport());
case SOFLOW_SO6:
return new ServiceDeviceSupport(new SoFlowSupport());
+ case VIVOMOVE_HR:
+ return new ServiceDeviceSupport(new VivomoveHrSupport());
}
return null;
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/BinaryUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/BinaryUtils.java
new file mode 100644
index 000000000..040b2d1ed
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/BinaryUtils.java
@@ -0,0 +1,50 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr;
+
+public final class BinaryUtils {
+ private BinaryUtils() {
+ }
+
+ public static int readByte(byte[] array, int offset) {
+ return array[offset] & 0xFF;
+ }
+
+ public static int readShort(byte[] array, int offset) {
+ return (array[offset] & 0xFF) | ((array[offset + 1] & 0xFF) << 8);
+ }
+
+ public static int readInt(byte[] array, int offset) {
+ return (array[offset] & 0xFF) | ((array[offset + 1] & 0xFF) << 8) | ((array[offset + 2] & 0xFF) << 16) | ((array[offset + 3] & 0xFF) << 24);
+ }
+
+ public static long readLong(byte[] array, int offset) {
+ return (array[offset] & 0xFFL) | ((array[offset + 1] & 0xFFL) << 8) | ((array[offset + 2] & 0xFFL) << 16) | ((array[offset + 3] & 0xFFL) << 24) |
+ ((array[offset + 4] & 0xFFL) << 32) | ((array[offset + 5] & 0xFFL) << 40) | ((array[offset + 6] & 0xFFL) << 48) | ((array[offset + 7] & 0xFFL) << 56);
+ }
+
+ public static void writeByte(byte[] array, int offset, int value) {
+ array[offset] = (byte) value;
+ }
+
+ public static void writeShort(byte[] array, int offset, int value) {
+ array[offset] = (byte) value;
+ array[offset + 1] = (byte) (value >> 8);
+ }
+
+ public static void writeInt(byte[] array, int offset, int value) {
+ array[offset] = (byte) value;
+ array[offset + 1] = (byte) (value >> 8);
+ array[offset + 2] = (byte) (value >> 16);
+ array[offset + 3] = (byte) (value >> 24);
+ }
+
+ public static void writeLong(byte[] array, int offset, long value) {
+ array[offset] = (byte) value;
+ array[offset + 1] = (byte) (value >> 8);
+ array[offset + 2] = (byte) (value >> 16);
+ array[offset + 3] = (byte) (value >> 24);
+ array[offset + 4] = (byte) (value >> 32);
+ array[offset + 5] = (byte) (value >> 40);
+ array[offset + 6] = (byte) (value >> 48);
+ array[offset + 7] = (byte) (value >> 56);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ChecksumCalculator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ChecksumCalculator.java
new file mode 100644
index 000000000..e695c9470
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ChecksumCalculator.java
@@ -0,0 +1,22 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr;
+
+public final class ChecksumCalculator {
+ private static final int[] CONSTANTS = {0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400};
+
+ private ChecksumCalculator() {
+ }
+
+ public static int computeCrc(byte[] data, int offset, int length) {
+ return computeCrc(0, data, offset, length);
+ }
+
+ public static int computeCrc(int initialCrc, byte[] data, int offset, int length) {
+ int crc = initialCrc;
+ for (int i = offset; i < offset + length; ++i) {
+ int b = data[i];
+ crc = (((crc >> 4) & 4095) ^ CONSTANTS[crc & 15]) ^ CONSTANTS[b & 15];
+ crc = (((crc >> 4) & 4095) ^ CONSTANTS[crc & 15]) ^ CONSTANTS[(b >> 4) & 15];
+ }
+ return crc;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminDeviceSetting.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminDeviceSetting.java
new file mode 100644
index 000000000..288a48eaa
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminDeviceSetting.java
@@ -0,0 +1,13 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr;
+
+public enum GarminDeviceSetting {
+ DEVICE_NAME,
+ CURRENT_TIME,
+ DAYLIGHT_SAVINGS_TIME_OFFSET,
+ TIME_ZONE_OFFSET,
+ NEXT_DAYLIGHT_SAVINGS_START,
+ NEXT_DAYLIGHT_SAVINGS_END,
+ AUTO_UPLOAD_ENABLED,
+ WEATHER_CONDITIONS_ENABLED,
+ WEATHER_ALERTS_ENABLED;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminMessageType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminMessageType.java
new file mode 100644
index 000000000..fef0e50d8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminMessageType.java
@@ -0,0 +1,28 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr;
+
+public enum GarminMessageType {
+ SCHEDULES,
+ SETTINGS,
+ GOALS,
+ WORKOUTS,
+ COURSES,
+ ACTIVITIES,
+ PERSONAL_RECORDS,
+ UNKNOWN_TYPE,
+ SOFTWARE_UPDATE,
+ DEVICE_SETTINGS,
+ LANGUAGE_SETTINGS,
+ USER_PROFILE,
+ SPORTS,
+ SEGMENT_LEADERS,
+ GOLF_CLUB,
+ WELLNESS_DEVICE_INFO,
+ WELLNESS_DEVICE_CCF,
+ INSTALL_APP,
+ CHECK_BACK,
+ TRUE_UP,
+ SETTINGS_CHANGE,
+ ACTIVITY_SUMMARY,
+ METRICS_FILE,
+ PACE_BAND
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminMusicControlCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminMusicControlCommand.java
new file mode 100644
index 000000000..0e86765fb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminMusicControlCommand.java
@@ -0,0 +1,13 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr;
+
+public enum GarminMusicControlCommand {
+ TOGGLE_PLAY_PAUSE,
+ SKIP_TO_NEXT_ITEM,
+ SKIP_TO_PREVIOUS_ITEM,
+ VOLUME_UP,
+ VOLUME_DOWN,
+ PLAY,
+ PAUSE,
+ SKIP_FORWARD,
+ SKIP_BACKWARDS;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminSystemEventType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminSystemEventType.java
new file mode 100644
index 000000000..256261d05
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminSystemEventType.java
@@ -0,0 +1,21 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr;
+
+public enum GarminSystemEventType {
+ SYNC_COMPLETE,
+ SYNC_FAIL,
+ FACTORY_RESET,
+ PAIR_START,
+ PAIR_COMPLETE,
+ PAIR_FAIL,
+ HOST_DID_ENTER_FOREGROUND,
+ HOST_DID_ENTER_BACKGROUND,
+ SYNC_READY,
+ NEW_DOWNLOAD_AVAILABLE,
+ DEVICE_SOFTWARE_UPDATE,
+ DEVICE_DISCONNECT,
+ TUTORIAL_COMPLETE,
+ SETUP_WIZARD_START,
+ SETUP_WIZARD_COMPLETE,
+ SETUP_WIZARD_SKIPPED,
+ TIME_UPDATED;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminTimeUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminTimeUtils.java
new file mode 100644
index 000000000..08983d73b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminTimeUtils.java
@@ -0,0 +1,24 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants;
+
+public final class GarminTimeUtils {
+ private GarminTimeUtils() {
+ }
+
+ public static int unixTimeToGarminTimestamp(int unixTime) {
+ return unixTime - VivomoveConstants.GARMIN_TIME_EPOCH;
+ }
+
+ public static int javaMillisToGarminTimestamp(long millis) {
+ return (int) (millis / 1000) - VivomoveConstants.GARMIN_TIME_EPOCH;
+ }
+
+ public static long garminTimestampToJavaMillis(int timestamp) {
+ return (timestamp + VivomoveConstants.GARMIN_TIME_EPOCH) * 1000L;
+ }
+
+ public static int garminTimestampToUnixTime(int timestamp) {
+ return timestamp + VivomoveConstants.GARMIN_TIME_EPOCH;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GfdiPacketParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GfdiPacketParser.java
new file mode 100644
index 000000000..8c5b670e3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GfdiPacketParser.java
@@ -0,0 +1,175 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+
+/**
+ * Parser of GFDI messages embedded in COBS packets.
+ *
+ * COBS ensures there are no embedded NUL bytes inside the packet data, and wraps the message into NUL framing bytes.
+ */
+// Notes: not really optimized; does a lot of (re)allocation, might use more static buffers, I guess… And code cleanup as well.
+public class GfdiPacketParser {
+ private static final Logger LOG = LoggerFactory.getLogger(GfdiPacketParser.class);
+
+ private static final long BUFFER_TIMEOUT = 1500L;
+ private static final byte[] EMPTY_BUFFER = new byte[0];
+ private static final byte[] BUFFER_FRAMING = new byte[1];
+
+ private byte[] buffer = EMPTY_BUFFER;
+ private byte[] packet;
+ private byte[] packetBuffer;
+ private int bufferPos;
+ private long lastUpdate;
+ private boolean insidePacket;
+
+ public void reset() {
+ buffer = EMPTY_BUFFER;
+ bufferPos = 0;
+ insidePacket = false;
+ packet = null;
+ packetBuffer = EMPTY_BUFFER;
+ }
+
+ public void receivedBytes(byte[] bytes) {
+ final long now = System.currentTimeMillis();
+ if ((now - lastUpdate) > BUFFER_TIMEOUT) {
+ reset();
+ }
+ lastUpdate = now;
+ final int bufferSize = buffer.length;
+ buffer = Arrays.copyOf(buffer, bufferSize + bytes.length);
+ System.arraycopy(bytes, 0, buffer, bufferSize, bytes.length);
+ parseBuffer();
+ }
+
+ public byte[] retrievePacket() {
+ final byte[] resultPacket = packet;
+ packet = null;
+ parseBuffer();
+ return resultPacket;
+ }
+
+ private void parseBuffer() {
+ if (packet != null) {
+ // packet is waiting, unable to parse more
+ return;
+ }
+ if (bufferPos >= buffer.length) {
+ // nothing to parse
+ return;
+ }
+ boolean startOfPacket = !insidePacket;
+ if (startOfPacket) {
+ byte b;
+ while (bufferPos < buffer.length && (b = buffer[bufferPos++]) != 0) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Unexpected non-zero byte while looking for framing: {}", Integer.toHexString(b));
+ }
+ }
+ if (bufferPos >= buffer.length) {
+ // nothing to parse
+ return;
+ }
+ insidePacket = true;
+ }
+ boolean endedWithFullChunk = false;
+ while (bufferPos < buffer.length) {
+ int chunkSize = -1;
+ int chunkStart = bufferPos;
+ int pos = bufferPos;
+ while (pos < buffer.length && ((chunkSize = (buffer[pos++] & 0xFF)) == 0) && startOfPacket) {
+ // skip repeating framing bytes (?)
+ bufferPos = pos;
+ chunkStart = pos;
+ }
+ if (startOfPacket && pos >= buffer.length) {
+ // incomplete framing, needs to wait for more data and try again
+ buffer = BUFFER_FRAMING;
+ bufferPos = 0;
+ insidePacket = false;
+ return;
+ }
+ assert chunkSize >= 0;
+ if (chunkSize == 0) {
+ // end of packet
+ // drop the last zero
+ if (endedWithFullChunk) {
+ // except when it was explicitly added (TODO: ugly, is it correct?)
+ packet = packetBuffer;
+ } else {
+ packet = Arrays.copyOf(packetBuffer, packetBuffer.length - 1);
+ }
+ packetBuffer = EMPTY_BUFFER;
+ insidePacket = false;
+
+ if (bufferPos == buffer.length - 1) {
+ buffer = EMPTY_BUFFER;
+ bufferPos = 0;
+ } else {
+ // TODO: Realloc buffer down
+ ++bufferPos;
+ }
+ return;
+ }
+ if (chunkStart + chunkSize > buffer.length) {
+ // incomplete chunk, needs to wait for more data
+ return;
+ }
+
+ // completed chunk
+ final int packetPos = packetBuffer.length;
+ final int realChunkSize = chunkSize < 255 ? chunkSize : chunkSize - 1;
+ packetBuffer = Arrays.copyOf(packetBuffer, packetPos + realChunkSize);
+ System.arraycopy(buffer, chunkStart + 1, packetBuffer, packetPos, chunkSize - 1);
+ bufferPos = chunkStart + chunkSize;
+
+ endedWithFullChunk = chunkSize == 255;
+ startOfPacket = false;
+ }
+ }
+
+ public static byte[] wrapMessageToPacket(byte[] message) {
+ try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(message.length + 2 + (message.length + 253) / 254)) {
+ outputStream.write(0);
+ int chunkStart = 0;
+ for (int i = 0; i < message.length; ++i) {
+ if (message[i] == 0) {
+ chunkStart = appendChunk(message, outputStream, chunkStart, i);
+ }
+ }
+ if (chunkStart <= message.length) {
+ appendChunk(message, outputStream, chunkStart, message.length);
+ }
+ outputStream.write(0);
+ return outputStream.toByteArray();
+ } catch (IOException e) {
+ LOG.error("Error writing to memory buffer", e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static int appendChunk(byte[] message, ByteArrayOutputStream outputStream, int chunkStart, int messagePos) {
+ int chunkLength = messagePos - chunkStart;
+ while (true) {
+ if (chunkLength >= 255) {
+ // write 255-byte chunk
+ outputStream.write(255);
+ outputStream.write(message, chunkStart, 254);
+ chunkLength -= 254;
+ chunkStart += 254;
+ } else {
+ // write chunk from chunkStart to here
+ outputStream.write(chunkLength + 1);
+ outputStream.write(message, chunkStart, chunkLength);
+ chunkStart = messagePos + 1;
+ break;
+ }
+ }
+ return chunkStart;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/RealTimeActivityHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/RealTimeActivityHandler.java
new file mode 100644
index 000000000..39a41f51b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/RealTimeActivityHandler.java
@@ -0,0 +1,166 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr;
+
+import android.content.Intent;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveHrSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.entities.VivomoveHrActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.UUID;
+
+import static nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils.readByte;
+import static nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils.readInt;
+import static nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils.readShort;
+
+/* default */ class RealTimeActivityHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(RealTimeActivityHandler.class);
+
+ private final VivomoveHrSupport owner;
+ private final VivomoveHrActivitySample lastSample = new VivomoveHrActivitySample();
+
+ /* default */ RealTimeActivityHandler(VivomoveHrSupport owner) {
+ this.owner = owner;
+ }
+
+ public boolean tryHandleChangedCharacteristic(UUID characteristicUUID, byte[] data) {
+ if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_HEART_RATE.equals(characteristicUUID)) {
+ processRealtimeHeartRate(data);
+ return true;
+ }
+ if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_STEPS.equals(characteristicUUID)) {
+ processRealtimeSteps(data);
+ return true;
+ }
+ if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_CALORIES.equals(characteristicUUID)) {
+ processRealtimeCalories(data);
+ return true;
+ }
+ if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_STAIRS.equals(characteristicUUID)) {
+ processRealtimeStairs(data);
+ return true;
+ }
+ if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_INTENSITY.equals(characteristicUUID)) {
+ processRealtimeIntensityMinutes(data);
+ return true;
+ }
+ if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_HEART_RATE_VARIATION.equals(characteristicUUID)) {
+ handleRealtimeHeartbeat(data);
+ return true;
+ }
+
+ return false;
+ }
+
+ private void processRealtimeHeartRate(byte[] data) {
+ int unknown1 = readByte(data, 0);
+ int heartRate = readByte(data, 1);
+ int unknown2 = readByte(data, 2);
+ int unknown3 = readShort(data, 3);
+
+ lastSample.setHeartRate(heartRate);
+ processSample();
+
+ LOG.debug("Realtime HR {} ({}, {}, {})", heartRate, unknown1, unknown2, unknown3);
+ }
+
+ private void processRealtimeSteps(byte[] data) {
+ int steps = readInt(data, 0);
+ int goal = readInt(data, 4);
+
+ lastSample.setSteps(steps);
+ processSample();
+
+ LOG.debug("Realtime steps: {} steps (goal: {})", steps, goal);
+ }
+
+ private void processRealtimeCalories(byte[] data) {
+ int calories = readInt(data, 0);
+ int unknown = readInt(data, 4);
+
+ lastSample.setCaloriesBurnt(calories);
+ processSample();
+
+ LOG.debug("Realtime calories: {} cal burned (unknown: {})", calories, unknown);
+ }
+
+ private void processRealtimeStairs(byte[] data) {
+ int floorsClimbed = readShort(data, 0);
+ int unknown = readShort(data, 2);
+ int floorGoal = readShort(data, 4);
+
+ lastSample.setFloorsClimbed(floorsClimbed);
+ processSample();
+
+ LOG.debug("Realtime stairs: {} floors climbed (goal: {}, unknown: {})", floorsClimbed, floorGoal, unknown);
+ }
+
+ private void processRealtimeIntensityMinutes(byte[] data) {
+ int weeklyLimit = readInt(data, 10);
+
+ LOG.debug("Realtime intensity recorded; weekly limit: {}", weeklyLimit);
+ }
+
+ private void handleRealtimeHeartbeat(byte[] data) {
+ int interval = readShort(data, 0);
+ int timer = readInt(data, 2);
+
+ float heartRate = (60.0f * 1024.0f) / interval;
+ LOG.debug("Realtime heartbeat frequency {} at {}", heartRate, timer);
+ }
+
+ private void processSample() {
+ if (lastSample.getCaloriesBurnt() == null || lastSample.getFloorsClimbed() == null || lastSample.getHeartRate() == 0 || lastSample.getSteps() == 0) {
+ LOG.debug("Skipping incomplete sample");
+ return;
+ }
+
+ try (final DBHandler dbHandler = GBApplication.acquireDB()) {
+ final DaoSession session = dbHandler.getDaoSession();
+
+ final GBDevice gbDevice = owner.getDevice();
+ final Device device = DBHelper.getDevice(gbDevice, session);
+ final User user = DBHelper.getUser(session);
+ final int ts = (int) (System.currentTimeMillis() / 1000);
+ final VivomoveHrSampleProvider provider = new VivomoveHrSampleProvider(gbDevice, session);
+ final VivomoveHrActivitySample sample = createActivitySample(device, user, ts, provider);
+
+ sample.setCaloriesBurnt(lastSample.getCaloriesBurnt());
+ sample.setFloorsClimbed(lastSample.getFloorsClimbed());
+ sample.setHeartRate(lastSample.getHeartRate());
+ sample.setSteps(lastSample.getSteps());
+ sample.setRawIntensity(ActivitySample.NOT_MEASURED);
+ sample.setRawKind(ActivityKind.TYPE_ACTIVITY); // to make it visible in the charts TODO: add a MANUAL kind for that?
+
+ LOG.debug("Publishing sample");
+ provider.addGBActivitySample(sample);
+ } catch (Exception e) {
+ LOG.error("Error saving real-time activity data", e);
+ }
+
+ final Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES)
+ .putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, lastSample);
+ LocalBroadcastManager.getInstance(owner.getContext()).sendBroadcast(intent);
+ }
+
+ public VivomoveHrActivitySample createActivitySample(Device device, User user, int timestampInSeconds, VivomoveHrSampleProvider provider) {
+ final VivomoveHrActivitySample sample = new VivomoveHrActivitySample();
+ sample.setDevice(device);
+ sample.setUser(user);
+ sample.setTimestamp(timestampInSeconds);
+ sample.setProvider(provider);
+
+ return sample;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrCommunicator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrCommunicator.java
new file mode 100644
index 000000000..2f2ddf215
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrCommunicator.java
@@ -0,0 +1,91 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr;
+
+import android.bluetooth.BluetoothGattCharacteristic;
+import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Arrays;
+
+public class VivomoveHrCommunicator {
+ private static final Logger LOG = LoggerFactory.getLogger(VivomoveHrCommunicator.class);
+
+ private final AbstractBTLEDeviceSupport deviceSupport;
+
+ private BluetoothGattCharacteristic characteristicMessageSender;
+ private BluetoothGattCharacteristic characteristicMessageReceiver;
+ private BluetoothGattCharacteristic characteristicHeartRate;
+ private BluetoothGattCharacteristic characteristicSteps;
+ private BluetoothGattCharacteristic characteristicCalories;
+ private BluetoothGattCharacteristic characteristicStairs;
+ private BluetoothGattCharacteristic characteristicHrVariation;
+ private BluetoothGattCharacteristic char2_9;
+
+ public VivomoveHrCommunicator(AbstractBTLEDeviceSupport deviceSupport) {
+ this.deviceSupport = deviceSupport;
+
+ this.characteristicMessageSender = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_GFDI_SEND);
+ this.characteristicMessageReceiver = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_GFDI_RECEIVE);
+ this.characteristicHeartRate = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_HEART_RATE);
+ this.characteristicSteps = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_STEPS);
+ this.characteristicCalories = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_CALORIES);
+ this.characteristicStairs = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_STAIRS);
+ this.characteristicHrVariation = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_HEART_RATE_VARIATION);
+ this.char2_9 = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_2_9);
+ }
+
+ public void start(TransactionBuilder builder) {
+ builder.notify(characteristicMessageReceiver, true);
+// builder.notify(characteristicHeartRate, true);
+// builder.notify(characteristicSteps, true);
+// builder.notify(characteristicCalories, true);
+// builder.notify(characteristicStairs, true);
+ //builder.notify(char2_7, true);
+ // builder.notify(char2_9, true);
+ }
+
+ public void sendMessage(byte[] messageBytes) {
+ try {
+ final TransactionBuilder builder = deviceSupport.performInitialized("sendMessage()");
+ sendMessage(builder, messageBytes);
+ builder.queue(deviceSupport.getQueue());
+ } catch (IOException e) {
+ LOG.error("Unable to send a message", e);
+ }
+ }
+
+ private void sendMessage(TransactionBuilder builder, byte[] messageBytes) {
+ final byte[] packet = GfdiPacketParser.wrapMessageToPacket(messageBytes);
+ int remainingBytes = packet.length;
+ if (remainingBytes > VivomoveConstants.MAX_WRITE_SIZE) {
+ int position = 0;
+ while (remainingBytes > 0) {
+ final byte[] fragment = Arrays.copyOfRange(packet, position, position + Math.min(remainingBytes, VivomoveConstants.MAX_WRITE_SIZE));
+ builder.write(characteristicMessageSender, fragment);
+ position += fragment.length;
+ remainingBytes -= fragment.length;
+ }
+ } else {
+ builder.write(characteristicMessageSender, packet);
+ }
+ }
+
+ public void enableRealtimeSteps(boolean enable) {
+ try {
+ deviceSupport.performInitialized((enable ? "Enable" : "Disable") + " realtime steps").notify(characteristicSteps, enable).queue(deviceSupport.getQueue());
+ } catch (IOException e) {
+ LOG.error("Unable to change realtime steps notification to: " + enable, e);
+ }
+ }
+
+ public void enableRealtimeHeartRate(boolean enable) {
+ try {
+ deviceSupport.performInitialized((enable ? "Enable" : "Disable") + " realtime heartrate").notify(characteristicHeartRate, enable).queue(deviceSupport.getQueue());
+ } catch (IOException ex) {
+ LOG.error("Unable to change realtime steps notification to: " + enable, ex);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrSupport.java
new file mode 100644
index 000000000..d8b22b4f7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrSupport.java
@@ -0,0 +1,1059 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr;
+
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.os.Build;
+import android.widget.Toast;
+import com.google.protobuf.InvalidProtocolBufferException;
+import de.greenrobot.dao.query.Query;
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
+import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
+import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.DownloadedFitFile;
+import nodomain.freeyourgadget.gadgetbridge.entities.DownloadedFitFileDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryState;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ams.AmsEntity;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ams.AmsEntityAttribute;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsAttribute;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsAttributeRequest;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsEvent;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsGetNotificationAttributeCommand;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsGetNotificationAttributesResponse;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.GncsDataSourceQueue;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads.DirectoryData;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads.DirectoryEntry;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads.FileDownloadListener;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads.FileDownloadQueue;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitBool;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitDbImporter;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitMessageDefinitions;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitParser;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitWeatherConditions;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.AuthNegotiationMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.AuthNegotiationResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.BatteryStatusMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.ConfigurationMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.CreateFileResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.CurrentTimeRequestMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.CurrentTimeRequestResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DeviceInformationMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DeviceInformationResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DirectoryFileFilterRequestMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DirectoryFileFilterResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DownloadRequestResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FileTransferDataMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FileTransferDataResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FindMyPhoneRequestMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FitDataMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FitDataResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FitDefinitionMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FitDefinitionResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GenericResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GncsControlPointMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GncsControlPointResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GncsDataSourceResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GncsNotificationSourceMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MusicControlCapabilitiesMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MusicControlCapabilitiesResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MusicControlEntityUpdateMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.NotificationServiceSubscriptionMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.NotificationServiceSubscriptionResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.ProtobufRequestMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.ProtobufRequestResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.ResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SetDeviceSettingsMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SetDeviceSettingsResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SupportedFileTypesRequestMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SupportedFileTypesResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SyncRequestMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SystemEventMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SystemEventResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.UploadRequestResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.WeatherRequestMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.WeatherRequestResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.notifications.NotificationData;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.notifications.NotificationStorage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.protobuf.GdiCore;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.protobuf.GdiDeviceStatus;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.protobuf.GdiFindMyWatch;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.protobuf.GdiSmartProto;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.uploads.FileUploadQueue;
+import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils.readShort;
+
+public class VivomoveHrSupport extends AbstractBTLEDeviceSupport implements FileDownloadListener {
+ private static final Logger LOG = LoggerFactory.getLogger(VivomoveHrSupport.class);
+
+ // Should all FIT data files be fully stored into the database? (Currently, there is no user-friendly way to
+ // retrieve them, anyway.)
+ // TODO: Choose what to do with them. Either store them or remove the field from DB.
+ private static final boolean STORE_FIT_FILES = false;
+
+ private final GfdiPacketParser gfdiPacketParser = new GfdiPacketParser();
+ private Set capabilities;
+
+ private int lastProtobufRequestId;
+ private int maxPacketSize;
+
+ private final FitParser fitParser = new FitParser(FitMessageDefinitions.ALL_DEFINITIONS);
+ private final NotificationStorage notificationStorage = new NotificationStorage();
+ private VivomoveHrCommunicator communicator;
+ private RealTimeActivityHandler realTimeActivityHandler;
+ private GncsDataSourceQueue gncsDataSourceQueue;
+ private FileDownloadQueue fileDownloadQueue;
+ private FileUploadQueue fileUploadQueue;
+ private FitDbImporter fitImporter;
+ private boolean notificationSubscription;
+
+ public VivomoveHrSupport() {
+ super(LOG);
+
+ addSupportedService(VivomoveConstants.UUID_SERVICE_GARMIN_1);
+ addSupportedService(VivomoveConstants.UUID_SERVICE_GARMIN_2);
+ }
+
+ private int getNextProtobufRequestId() {
+ lastProtobufRequestId = (lastProtobufRequestId + 1) % 65536;
+ return lastProtobufRequestId;
+ }
+
+ @Override
+ protected TransactionBuilder initializeDevice(TransactionBuilder builder) {
+ LOG.info("Initializing");
+
+ gbDevice.setState(GBDevice.State.INITIALIZING);
+ gbDevice.sendDeviceUpdateIntent(getContext());
+
+ communicator = new VivomoveHrCommunicator(this);
+ realTimeActivityHandler = new RealTimeActivityHandler(this);
+
+ builder.setCallback(this);
+ communicator.start(builder);
+ fileDownloadQueue = new FileDownloadQueue(communicator, this);
+ fileUploadQueue = new FileUploadQueue(communicator);
+
+ LOG.info("Initialization Done");
+
+ // OK, this is not perfect: we should not be INITIALIZED until “connected AND all the necessary initialization
+ // steps have been performed. At the very least, this means that basic information like device name, firmware
+ // version, hardware revision (as applicable) is available in the GBDevice”. But we cannot send any message
+ // until we are INITIALIZED. So what can we do…
+ gbDevice.setState(GBDevice.State.INITIALIZED);
+ gbDevice.sendDeviceUpdateIntent(getContext());
+
+ sendMessage(new AuthNegotiationMessage(AuthNegotiationMessage.LONG_TERM_KEY_AVAILABILITY_NONE, AuthNegotiationMessage.ENCRYPTION_ALGORITHM_NONE).packet);
+
+ return builder;
+ }
+
+ @Override
+ public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
+ final UUID characteristicUUID = characteristic.getUuid();
+ if (super.onCharacteristicChanged(gatt, characteristic)) {
+ LOG.debug("Change of characteristic {} handled by parent", characteristicUUID);
+ return true;
+ }
+
+ final byte[] data = characteristic.getValue();
+ if (data.length == 0) {
+ LOG.debug("No data received on change of characteristic {}", characteristicUUID);
+ return true;
+ }
+
+ if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_GFDI_RECEIVE.equals(characteristicUUID)) {
+ handleReceivedGfdiBytes(data);
+ } else if (realTimeActivityHandler.tryHandleChangedCharacteristic(characteristicUUID, data)) {
+ // handled by real-time activity handler
+ } else {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Unknown characteristic {} changed: {}", characteristicUUID, GB.hexdump(data));
+ }
+ }
+
+ return true;
+ }
+
+ private void sendMessage(byte[] messageBytes) {
+ communicator.sendMessage(messageBytes);
+ }
+
+ private void handleReceivedGfdiBytes(byte[] data) {
+ gfdiPacketParser.receivedBytes(data);
+ LOG.debug("Received {} GFDI bytes", data.length);
+ byte[] packet;
+ while ((packet = gfdiPacketParser.retrievePacket()) != null) {
+ LOG.debug("Processing a {}B GFDI packet", packet.length);
+ processGfdiPacket(packet);
+ }
+ }
+
+ private void processGfdiPacket(byte[] packet) {
+ final int size = readShort(packet, 0);
+ if (size != packet.length) {
+ LOG.error("Received GFDI packet with invalid length: {} vs {}", size, packet.length);
+ return;
+ }
+ final int crc = readShort(packet, packet.length - 2);
+ final int correctCrc = ChecksumCalculator.computeCrc(packet, 0, packet.length - 2);
+ if (crc != correctCrc) {
+ LOG.error("Received GFDI packet with invalid CRC: {} vs {}", crc, correctCrc);
+ return;
+ }
+
+ final int messageType = readShort(packet, 2);
+ switch (messageType) {
+ case VivomoveConstants.MESSAGE_RESPONSE:
+ processResponseMessage(ResponseMessage.parsePacket(packet), packet);
+ break;
+
+ case VivomoveConstants.MESSAGE_FILE_TRANSFER_DATA:
+ fileDownloadQueue.onFileTransferData(FileTransferDataMessage.parsePacket(packet));
+ break;
+
+ case VivomoveConstants.MESSAGE_DEVICE_INFORMATION:
+ processDeviceInformationMessage(DeviceInformationMessage.parsePacket(packet));
+ break;
+
+ case VivomoveConstants.MESSAGE_WEATHER_REQUEST:
+ processWeatherRequest(WeatherRequestMessage.parsePacket(packet));
+ break;
+
+ case VivomoveConstants.MESSAGE_MUSIC_CONTROL_CAPABILITIES:
+ processMusicControlCapabilities(MusicControlCapabilitiesMessage.parsePacket(packet));
+ break;
+
+ case VivomoveConstants.MESSAGE_CURRENT_TIME_REQUEST:
+ processCurrentTimeRequest(CurrentTimeRequestMessage.parsePacket(packet));
+ break;
+
+ case VivomoveConstants.MESSAGE_SYNC_REQUEST:
+ processSyncRequest(SyncRequestMessage.parsePacket(packet));
+ break;
+
+ case VivomoveConstants.MESSAGE_FIND_MY_PHONE:
+ processFindMyPhoneRequest(FindMyPhoneRequestMessage.parsePacket(packet));
+ break;
+
+ case VivomoveConstants.MESSAGE_CANCEL_FIND_MY_PHONE:
+ processCancelFindMyPhoneRequest();
+ break;
+
+ case VivomoveConstants.MESSAGE_NOTIFICATION_SERVICE_SUBSCRIPTION:
+ processNotificationServiceSubscription(NotificationServiceSubscriptionMessage.parsePacket(packet));
+ break;
+
+ case VivomoveConstants.MESSAGE_GNCS_CONTROL_POINT_REQUEST:
+ processGncsControlPointRequest(GncsControlPointMessage.parsePacket(packet));
+ break;
+
+ case VivomoveConstants.MESSAGE_CONFIGURATION:
+ processConfigurationMessage(ConfigurationMessage.parsePacket(packet));
+ break;
+
+ case VivomoveConstants.MESSAGE_PROTOBUF_RESPONSE:
+ processProtobufResponse(ProtobufRequestMessage.parsePacket(packet));
+ break;
+
+ default:
+ if (LOG.isInfoEnabled()) {
+ LOG.info("Unknown message type {}: {}", messageType, GB.hexdump(packet, 0, packet.length));
+ }
+ break;
+ }
+ }
+
+ private void processCancelFindMyPhoneRequest() {
+ LOG.info("Processing request to cancel find-my-phone");
+ sendMessage(new GenericResponseMessage(VivomoveConstants.MESSAGE_FIND_MY_PHONE, 0).packet);
+
+ final GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone();
+ findPhoneEvent.event = GBDeviceEventFindPhone.Event.STOP;
+ evaluateGBDeviceEvent(findPhoneEvent);
+ }
+
+ private void processFindMyPhoneRequest(FindMyPhoneRequestMessage requestMessage) {
+ LOG.info("Processing find-my-phone request ({} s)", requestMessage.duration);
+
+ sendMessage(new GenericResponseMessage(VivomoveConstants.MESSAGE_FIND_MY_PHONE, 0).packet);
+
+ final GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone();
+ findPhoneEvent.event = GBDeviceEventFindPhone.Event.START;
+ evaluateGBDeviceEvent(findPhoneEvent);
+ }
+
+ private void processGncsControlPointRequest(GncsControlPointMessage requestMessage) {
+ if (requestMessage == null) {
+ // TODO: Proper error handling with specific error code
+ sendMessage(new GncsControlPointResponseMessage(VivomoveConstants.STATUS_ACK, GncsControlPointResponseMessage.RESPONSE_ANCS_ERROR_OCCURRED, GncsControlPointResponseMessage.ANCS_ERROR_UNKNOWN_ANCS_COMMAND).packet);
+ return;
+ }
+ switch (requestMessage.command.command) {
+ case GET_NOTIFICATION_ATTRIBUTES:
+ final AncsGetNotificationAttributeCommand getNotificationAttributeCommand = (AncsGetNotificationAttributeCommand) requestMessage.command;
+ LOG.info("Processing ANCS request to get attributes of notification #{}", getNotificationAttributeCommand.notificationUID);
+ sendMessage(new GncsControlPointResponseMessage(VivomoveConstants.STATUS_ACK, GncsControlPointResponseMessage.RESPONSE_SUCCESSFUL, GncsControlPointResponseMessage.ANCS_ERROR_NO_ERROR).packet);
+ final NotificationData notificationData = notificationStorage.retrieveNotification(getNotificationAttributeCommand.notificationUID);
+ if (notificationData == null) {
+ LOG.warn("Notification #{} not registered", getNotificationAttributeCommand.notificationUID);
+ }
+ final Map attributes = new LinkedHashMap<>();
+ for (final AncsAttributeRequest attributeRequest : getNotificationAttributeCommand.attributes) {
+ final AncsAttribute attribute = attributeRequest.attribute;
+ final String attributeValue = notificationData == null ? null : notificationData.getAttribute(attributeRequest.attribute);
+ final String valueShortened = attributeRequest.maxLength > 0 && attributeValue != null && attributeValue.length() > attributeRequest.maxLength ? attributeValue.substring(0, attributeRequest.maxLength) : attributeValue;
+ LOG.debug("Requested ANCS attribute {}: '{}'", attribute, valueShortened);
+ attributes.put(attribute, valueShortened == null ? "" : valueShortened);
+ }
+ gncsDataSourceQueue.addToQueue(new AncsGetNotificationAttributesResponse(getNotificationAttributeCommand.notificationUID, attributes).packet);
+ break;
+
+ default:
+ LOG.error("Unknown GNCS control point command {}", requestMessage.command.command);
+ sendMessage(new GncsControlPointResponseMessage(VivomoveConstants.STATUS_ACK, GncsControlPointResponseMessage.RESPONSE_ANCS_ERROR_OCCURRED, GncsControlPointResponseMessage.ANCS_ERROR_UNKNOWN_ANCS_COMMAND).packet);
+ break;
+ }
+ }
+
+ private void processNotificationServiceSubscription(NotificationServiceSubscriptionMessage requestMessage) {
+ LOG.info("Processing notification service subscription request message: intent={}, flags={}", requestMessage.intentIndicator, requestMessage.featureFlags);
+ notificationSubscription = requestMessage.intentIndicator == NotificationServiceSubscriptionMessage.INTENT_SUBSCRIBE;
+ sendMessage(new NotificationServiceSubscriptionResponseMessage(0, 0, requestMessage.intentIndicator, requestMessage.featureFlags).packet);
+ }
+
+ private void processSyncRequest(SyncRequestMessage requestMessage) {
+ if (LOG.isInfoEnabled()) {
+ final StringBuilder requestedTypes = new StringBuilder();
+ for (GarminMessageType type : requestMessage.fileTypes) {
+ if (requestedTypes.length() > 0) {
+ requestedTypes.append(", ");
+ }
+ requestedTypes.append(type);
+ }
+ LOG.info("Processing sync request message: option={}, types: {}", requestMessage.option, requestedTypes);
+ }
+ sendMessage(new GenericResponseMessage(VivomoveConstants.MESSAGE_SYNC_REQUEST, 0).packet);
+ if (requestMessage.option != SyncRequestMessage.OPTION_INVISIBLE) {
+ doSync();
+ }
+ }
+
+ private void processProtobufResponse(ProtobufRequestMessage requestMessage) {
+ LOG.info("Received protobuf response #{}, {}B@{}/{}: {}", requestMessage.requestId, requestMessage.protobufDataLength, requestMessage.dataOffset, requestMessage.totalProtobufLength, GB.hexdump(requestMessage.messageBytes, 0, requestMessage.messageBytes.length));
+ sendMessage(new GenericResponseMessage(VivomoveConstants.MESSAGE_PROTOBUF_RESPONSE, 0).packet);
+ final GdiSmartProto.Smart smart;
+ try {
+ smart = GdiSmartProto.Smart.parseFrom(requestMessage.messageBytes);
+ } catch (InvalidProtocolBufferException e) {
+ LOG.error("Failed to parse protobuf message ({}): {}", e.getLocalizedMessage(), GB.hexdump(requestMessage.messageBytes, 0, requestMessage.messageBytes.length));
+ return;
+ }
+ boolean processed = false;
+ if (smart.hasDeviceStatusService()) {
+ processProtobufDeviceStatusResponse(smart.getDeviceStatusService());
+ processed = true;
+ }
+ if (smart.hasFindMyWatchService()) {
+ processProtobufFindMyWatchResponse(smart.getFindMyWatchService());
+ processed = true;
+ }
+ if (smart.hasCoreService()) {
+ processProtobufCoreResponse(smart.getCoreService());
+ processed = true;
+ }
+ if (!processed) {
+ LOG.warn("Unknown protobuf response: {}", smart);
+ }
+ }
+
+ private void processProtobufCoreResponse(GdiCore.CoreService coreService) {
+ if (coreService.hasSyncResponse()) {
+ final GdiCore.CoreService.SyncResponse syncResponse = coreService.getSyncResponse();
+ LOG.info("Received sync status: {}", syncResponse.getStatus());
+ }
+ LOG.warn("Unknown CoreService response: {}", coreService);
+ }
+
+ private void processProtobufDeviceStatusResponse(GdiDeviceStatus.DeviceStatusService deviceStatusService) {
+ if (deviceStatusService.hasRemoteDeviceBatteryStatusResponse()) {
+ final GdiDeviceStatus.DeviceStatusService.RemoteDeviceBatteryStatusResponse batteryStatusResponse = deviceStatusService.getRemoteDeviceBatteryStatusResponse();
+ final int batteryLevel = batteryStatusResponse.getCurrentBatteryLevel();
+ LOG.info("Received remote battery status {}: level={}", batteryStatusResponse.getStatus(), batteryLevel);
+ final GBDeviceEventBatteryInfo batteryEvent = new GBDeviceEventBatteryInfo();
+ batteryEvent.level = (short) batteryLevel;
+ handleGBDeviceEvent(batteryEvent);
+ return;
+ }
+ if (deviceStatusService.hasActivityStatusResponse()) {
+ final GdiDeviceStatus.DeviceStatusService.ActivityStatusResponse activityStatusResponse = deviceStatusService.getActivityStatusResponse();
+ LOG.info("Received activity status: {}", activityStatusResponse.getStatus());
+ return;
+ }
+ LOG.warn("Unknown DeviceStatusService response: {}", deviceStatusService);
+ }
+
+ private void processProtobufFindMyWatchResponse(GdiFindMyWatch.FindMyWatchService findMyWatchService) {
+ if (findMyWatchService.hasCancelRequest()) {
+ LOG.info("Watch search cancelled, watch found");
+ GBApplication.deviceService().onFindDevice(false);
+ return;
+ }
+ if (findMyWatchService.hasCancelResponse() || findMyWatchService.hasFindResponse()) {
+ LOG.debug("Received findMyWatch response");
+ return;
+ }
+ LOG.warn("Unknown FindMyWatchService response: {}", findMyWatchService);
+ }
+
+ private void processMusicControlCapabilities(MusicControlCapabilitiesMessage capabilitiesMessage) {
+ LOG.info("Processing music control capabilities request caps={}", capabilitiesMessage.supportedCapabilities);
+ sendMessage(new MusicControlCapabilitiesResponseMessage(0, GarminMusicControlCommand.values()).packet);
+ }
+
+ private void processWeatherRequest(WeatherRequestMessage requestMessage) {
+ LOG.info("Processing weather request fmt={}, {} hrs, {}/{}", requestMessage.format, requestMessage.hoursOfForecast, requestMessage.latitude, requestMessage.longitude);
+ sendMessage(new WeatherRequestResponseMessage(0, 0, 1, 300).packet);
+ }
+
+ private void processCurrentTimeRequest(CurrentTimeRequestMessage requestMessage) {
+ long now = System.currentTimeMillis();
+ final TimeZone timeZone = TimeZone.getDefault();
+ final Calendar calendar = Calendar.getInstance(timeZone);
+ calendar.setTimeInMillis(now);
+ int dstOffset = calendar.get(Calendar.DST_OFFSET) / 1000;
+ int timeZoneOffset = timeZone.getOffset(now) / 1000;
+ int garminTimestamp = GarminTimeUtils.javaMillisToGarminTimestamp(now);
+
+ LOG.info("Processing current time request #{}: time={}, DST={}, ofs={}", requestMessage.referenceID, garminTimestamp, dstOffset, timeZoneOffset);
+ sendMessage(new CurrentTimeRequestResponseMessage(0, requestMessage.referenceID, garminTimestamp, timeZoneOffset, dstOffset).packet);
+ }
+
+ private void processResponseMessage(ResponseMessage responseMessage, byte[] packet) {
+ switch (responseMessage.requestID) {
+ case VivomoveConstants.MESSAGE_DIRECTORY_FILE_FILTER_REQUEST:
+ processDirectoryFileFilterResponse(DirectoryFileFilterResponseMessage.parsePacket(packet));
+ break;
+ case VivomoveConstants.MESSAGE_DOWNLOAD_REQUEST:
+ fileDownloadQueue.onDownloadRequestResponse(DownloadRequestResponseMessage.parsePacket(packet));
+ break;
+ case VivomoveConstants.MESSAGE_UPLOAD_REQUEST:
+ fileUploadQueue.onUploadRequestResponse(UploadRequestResponseMessage.parsePacket(packet));
+ break;
+ case VivomoveConstants.MESSAGE_FILE_TRANSFER_DATA:
+ fileUploadQueue.onFileTransferResponse(FileTransferDataResponseMessage.parsePacket(packet));
+ break;
+ case VivomoveConstants.MESSAGE_CREATE_FILE_REQUEST:
+ fileUploadQueue.onCreateFileRequestResponse(CreateFileResponseMessage.parsePacket(packet));
+ break;
+ case VivomoveConstants.MESSAGE_FIT_DEFINITION:
+ processFitDefinitionResponse(FitDefinitionResponseMessage.parsePacket(packet));
+ break;
+ case VivomoveConstants.MESSAGE_FIT_DATA:
+ processFitDataResponse(FitDataResponseMessage.parsePacket(packet));
+ break;
+ case VivomoveConstants.MESSAGE_PROTOBUF_REQUEST:
+ processProtobufRequestResponse(ProtobufRequestResponseMessage.parsePacket(packet));
+ break;
+ case VivomoveConstants.MESSAGE_DEVICE_SETTINGS:
+ processDeviceSettingsResponse(SetDeviceSettingsResponseMessage.parsePacket(packet));
+ break;
+ case VivomoveConstants.MESSAGE_SYSTEM_EVENT:
+ processSystemEventResponse(SystemEventResponseMessage.parsePacket(packet));
+ break;
+ case VivomoveConstants.MESSAGE_SUPPORTED_FILE_TYPES_REQUEST:
+ processSupportedFileTypesResponse(SupportedFileTypesResponseMessage.parsePacket(packet));
+ break;
+ case VivomoveConstants.MESSAGE_GNCS_DATA_SOURCE:
+ gncsDataSourceQueue.responseReceived(GncsDataSourceResponseMessage.parsePacket(packet));
+ break;
+ case VivomoveConstants.MESSAGE_AUTH_NEGOTIATION:
+ processAuthNegotiationRequestResponse(AuthNegotiationResponseMessage.parsePacket(packet));
+ break;
+ default:
+ LOG.info("Received response to message {}: {}", responseMessage.requestID, responseMessage.getStatusStr());
+ break;
+ }
+ }
+
+ private void processDirectoryFileFilterResponse(DirectoryFileFilterResponseMessage responseMessage) {
+ if (responseMessage.status == VivomoveConstants.STATUS_ACK && responseMessage.response == DirectoryFileFilterResponseMessage.RESPONSE_DIRECTORY_FILTER_APPLIED) {
+ LOG.info("Received response to directory file filter request: {}/{}, requesting download of directory data", responseMessage.status, responseMessage.response);
+ fileDownloadQueue.addToDownloadQueue(0, 0);
+ } else {
+ LOG.error("Directory file filter request failed: {}/{}", responseMessage.status, responseMessage.response);
+ }
+ }
+
+ private void processSupportedFileTypesResponse(SupportedFileTypesResponseMessage responseMessage) {
+ final StringBuilder supportedTypes = new StringBuilder();
+ for (SupportedFileTypesResponseMessage.FileTypeInfo type : responseMessage.fileTypes) {
+ if (supportedTypes.length() > 0) {
+ supportedTypes.append(", ");
+ }
+ supportedTypes.append(String.format(Locale.ROOT, "%d/%d: %s", type.fileDataType, type.fileSubType, type.garminDeviceFileType));
+ }
+ LOG.info("Received the list of supported file types (status={}): {}", responseMessage.status, supportedTypes);
+ }
+
+ private void processDeviceSettingsResponse(SetDeviceSettingsResponseMessage responseMessage) {
+ LOG.info("Received response to device settings message: status={}, response={}", responseMessage.status, responseMessage.response);
+ }
+
+ private void processAuthNegotiationRequestResponse(AuthNegotiationResponseMessage responseMessage) {
+ LOG.info("Received response to auth negotiation message: status={}, response={}, LTK={}, algorithms={}", responseMessage.status, responseMessage.response, responseMessage.longTermKeyAvailability, responseMessage.supportedEncryptionAlgorithms);
+ }
+
+ private void processSystemEventResponse(SystemEventResponseMessage responseMessage) {
+ LOG.info("Received response to system event message: status={}, response={}", responseMessage.status, responseMessage.response);
+ }
+
+ private void processFitDefinitionResponse(FitDefinitionResponseMessage responseMessage) {
+ LOG.info("Received response to FIT definition message: status={}, FIT response={}", responseMessage.status, responseMessage.fitResponse);
+ }
+
+ private void processFitDataResponse(FitDataResponseMessage responseMessage) {
+ LOG.info("Received response to FIT data message: status={}, FIT response={}", responseMessage.status, responseMessage.fitResponse);
+ }
+
+ private void processProtobufRequestResponse(ProtobufRequestResponseMessage responseMessage) {
+ LOG.info("Received response to protobuf message #{}@{}: status={}, error={}", responseMessage.requestId, responseMessage.dataOffset, responseMessage.protobufStatus, responseMessage.error);
+ }
+
+ private void processDeviceInformationMessage(DeviceInformationMessage deviceInformationMessage) {
+ LOG.info("Received device information: protocol {}, product {}, unit {}, SW {}, max packet {}, BT name {}, device name {}, device model {}", deviceInformationMessage.protocolVersion, deviceInformationMessage.productNumber, deviceInformationMessage.unitNumber, deviceInformationMessage.getSoftwareVersionStr(), deviceInformationMessage.maxPacketSize, deviceInformationMessage.bluetoothFriendlyName, deviceInformationMessage.deviceName, deviceInformationMessage.deviceModel);
+
+ this.maxPacketSize = deviceInformationMessage.maxPacketSize;
+ this.gncsDataSourceQueue = new GncsDataSourceQueue(communicator, maxPacketSize);
+
+ final GBDeviceEventVersionInfo deviceEventVersionInfo = new GBDeviceEventVersionInfo();
+ deviceEventVersionInfo.fwVersion = deviceInformationMessage.getSoftwareVersionStr();
+ handleGBDeviceEvent(deviceEventVersionInfo);
+
+ gbDevice.setState(GBDevice.State.INITIALIZED);
+ gbDevice.sendDeviceUpdateIntent(getContext());
+
+ // prepare and send response
+ final boolean protocolVersionSupported = deviceInformationMessage.protocolVersion / 100 == 1;
+ if (!protocolVersionSupported) {
+ LOG.error("Unsupported protocol version {}", deviceInformationMessage.protocolVersion);
+ }
+ final int protocolFlags = protocolVersionSupported ? 1 : 0;
+ final DeviceInformationResponseMessage deviceInformationResponseMessage = new DeviceInformationResponseMessage(VivomoveConstants.STATUS_ACK, 112, -1, VivomoveConstants.GADGETBRIDGE_UNIT_NUMBER, BuildConfig.VERSION_CODE, 16384, getBluetoothAdapter().getName(), Build.MANUFACTURER, Build.DEVICE, protocolFlags);
+
+ sendMessage(deviceInformationResponseMessage.packet);
+ }
+
+ private void processConfigurationMessage(ConfigurationMessage configurationMessage) {
+ this.capabilities = GarminCapability.setFromBinary(configurationMessage.configurationPayload);
+
+ if (LOG.isInfoEnabled()) {
+ LOG.info("Received configuration message; capabilities: {}", GarminCapability.setToString(capabilities));
+ }
+
+ // prepare and send response
+ sendMessage(new GenericResponseMessage(VivomoveConstants.MESSAGE_CONFIGURATION, VivomoveConstants.STATUS_ACK).packet);
+
+ // and report our own configuration/capabilities
+ final byte[] ourCapabilityFlags = GarminCapability.setToBinary(VivomoveConstants.OUR_CAPABILITIES);
+ sendMessage(new ConfigurationMessage(ourCapabilityFlags).packet);
+
+ // initialize current time and settings
+ sendCurrentTime();
+ sendSettings();
+
+ // and everything is ready now
+ sendSyncReady();
+ requestBatteryStatusUpdate();
+ sendFitDefinitions();
+ sendFitConnectivityMessage();
+ requestSupportedFileTypes();
+ }
+
+ private void sendProtobufRequest(byte[] protobufMessage) {
+ final int requestId = getNextProtobufRequestId();
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Sending {}B protobuf request #{}: {}", protobufMessage.length, requestId, GB.hexdump(protobufMessage, 0, protobufMessage.length));
+ }
+ sendMessage(new ProtobufRequestMessage(requestId, 0, protobufMessage.length, protobufMessage.length, protobufMessage).packet);
+ }
+
+ private void requestBatteryStatusUpdate() {
+ sendProtobufRequest(
+ GdiSmartProto.Smart.newBuilder()
+ .setDeviceStatusService(
+ GdiDeviceStatus.DeviceStatusService.newBuilder()
+ .setRemoteDeviceBatteryStatusRequest(
+ GdiDeviceStatus.DeviceStatusService.RemoteDeviceBatteryStatusRequest.newBuilder()
+ )
+ )
+ .build()
+ .toByteArray());
+ }
+
+ private void requestActivityStatus() {
+ sendProtobufRequest(
+ GdiSmartProto.Smart.newBuilder()
+ .setDeviceStatusService(
+ GdiDeviceStatus.DeviceStatusService.newBuilder()
+ .setActivityStatusRequest(
+ GdiDeviceStatus.DeviceStatusService.ActivityStatusRequest.newBuilder()
+ )
+ )
+ .build()
+ .toByteArray());
+ }
+
+ private void sendRequestSync() {
+ sendProtobufRequest(
+ GdiSmartProto.Smart.newBuilder()
+ .setCoreService(
+ GdiCore.CoreService.newBuilder()
+ .setSyncRequest(
+ GdiCore.CoreService.SyncRequest.newBuilder()
+ )
+ )
+ .build()
+ .toByteArray());
+ }
+
+ private void requestSupportedFileTypes() {
+ LOG.info("Requesting list of supported file types");
+ sendMessage(new SupportedFileTypesRequestMessage().packet);
+ }
+
+ private void sendSyncReady() {
+ sendMessage(new SystemEventMessage(GarminSystemEventType.SYNC_READY, 0).packet);
+ }
+
+ private void sendCurrentTime() {
+ final Map settings = new LinkedHashMap<>(3);
+
+ long now = System.currentTimeMillis();
+ final TimeZone timeZone = TimeZone.getDefault();
+ final Calendar calendar = Calendar.getInstance(timeZone);
+ calendar.setTimeInMillis(now);
+ int dstOffset = calendar.get(Calendar.DST_OFFSET) / 1000;
+ int timeZoneOffset = timeZone.getOffset(now) / 1000;
+ int garminTimestamp = GarminTimeUtils.javaMillisToGarminTimestamp(now);
+
+ settings.put(GarminDeviceSetting.CURRENT_TIME, garminTimestamp);
+ settings.put(GarminDeviceSetting.DAYLIGHT_SAVINGS_TIME_OFFSET, dstOffset);
+ settings.put(GarminDeviceSetting.TIME_ZONE_OFFSET, timeZoneOffset);
+ // TODO: NEXT_DAYLIGHT_SAVINGS_START, NEXT_DAYLIGHT_SAVINGS_END
+ LOG.info("Setting time to {}, dstOffset={}, tzOffset={} (DST={})", garminTimestamp, dstOffset, timeZoneOffset, timeZone.inDaylightTime(new Date(now)) ? 1 : 0);
+ sendMessage(new SetDeviceSettingsMessage(settings).packet);
+ }
+
+ private void sendSettings() {
+ final Map settings = new LinkedHashMap<>(3);
+
+ settings.put(GarminDeviceSetting.WEATHER_CONDITIONS_ENABLED, true);
+ settings.put(GarminDeviceSetting.WEATHER_ALERTS_ENABLED, true);
+ settings.put(GarminDeviceSetting.AUTO_UPLOAD_ENABLED, true);
+ LOG.info("Sending settings");
+ sendMessage(new SetDeviceSettingsMessage(settings).packet);
+ }
+
+ private void sendFitDefinitions() {
+ sendMessage(new FitDefinitionMessage(
+ FitMessageDefinitions.DEFINITION_CONNECTIVITY,
+ FitMessageDefinitions.DEFINITION_WEATHER_CONDITIONS,
+ FitMessageDefinitions.DEFINITION_WEATHER_ALERT,
+ FitMessageDefinitions.DEFINITION_DEVICE_SETTINGS
+ ).packet);
+ }
+
+ private void sendFitConnectivityMessage() {
+ final FitMessage connectivityMessage = new FitMessage(FitMessageDefinitions.DEFINITION_CONNECTIVITY);
+ connectivityMessage.setField(0, FitBool.TRUE);
+ connectivityMessage.setField(1, FitBool.TRUE);
+ connectivityMessage.setField(2, FitBool.INVALID);
+ connectivityMessage.setField(4, FitBool.TRUE);
+ connectivityMessage.setField(5, FitBool.TRUE);
+ connectivityMessage.setField(6, FitBool.TRUE);
+ connectivityMessage.setField(7, FitBool.TRUE);
+ connectivityMessage.setField(8, FitBool.TRUE);
+ connectivityMessage.setField(9, FitBool.TRUE);
+ connectivityMessage.setField(10, FitBool.TRUE);
+ connectivityMessage.setField(13, FitBool.TRUE);
+ sendMessage(new FitDataMessage(connectivityMessage).packet);
+ }
+
+ private void sendWeatherConditions(WeatherSpec weather) {
+ final FitMessage weatherConditionsMessage = new FitMessage(FitMessageDefinitions.DEFINITION_WEATHER_CONDITIONS);
+ weatherConditionsMessage.setField(253, GarminTimeUtils.unixTimeToGarminTimestamp(weather.timestamp));
+ weatherConditionsMessage.setField(0, 0); // 0 = current, 2 = hourly_forecast, 3 = daily_forecast
+ weatherConditionsMessage.setField(1, weather.currentTemp);
+ weatherConditionsMessage.setField(2, FitWeatherConditions.openWeatherCodeToFitWeatherStatus(weather.currentConditionCode));
+ weatherConditionsMessage.setField(3, weather.windDirection);
+ weatherConditionsMessage.setField(4, Math.round(weather.windSpeed * 1000.0 / 3.6));
+ weatherConditionsMessage.setField(7, weather.currentHumidity);
+ weatherConditionsMessage.setField(8, weather.location);
+ final Calendar timestamp = Calendar.getInstance();
+ timestamp.setTimeInMillis(weather.timestamp * 1000L);
+ weatherConditionsMessage.setField(12, timestamp.get(Calendar.DAY_OF_WEEK));
+ weatherConditionsMessage.setField(13, weather.todayMaxTemp);
+ weatherConditionsMessage.setField(14, weather.todayMinTemp);
+
+ sendMessage(new FitDataMessage(weatherConditionsMessage).packet);
+ }
+
+ /*
+ private void sendWeatherAlert() {
+ final FitMessage weatherConditionsMessage = new FitMessage(FitMessageDefinitions.DEFINITION_WEATHER_ALERT);
+ weatherConditionsMessage.setField(253, GarminTimeUtils.javaMillisToGarminTimestamp(System.currentTimeMillis()));
+ weatherConditionsMessage.setField(0, "TESTRPT");
+ final Calendar issue = Calendar.getInstance();
+ issue.set(2019, 8, 27, 0, 0, 0);
+ final Calendar expiry = Calendar.getInstance();
+ issue.set(2019, 8, 29, 0, 0, 0);
+ weatherConditionsMessage.setField(1, GarminTimeUtils.javaMillisToGarminTimestamp(issue.getTimeInMillis()));
+ weatherConditionsMessage.setField(2, GarminTimeUtils.javaMillisToGarminTimestamp(expiry.getTimeInMillis()));
+ weatherConditionsMessage.setField(3, FitWeatherConditions.ALERT_SEVERITY_ADVISORY);
+ weatherConditionsMessage.setField(4, FitWeatherConditions.ALERT_TYPE_SEVERE_THUNDERSTORM);
+
+ sendMessage(new FitDataMessage(weatherConditionsMessage).packet);
+ }
+ */
+
+ private void sendNotification(AncsEvent event, NotificationData notification) {
+ if (event == AncsEvent.NOTIFICATION_ADDED) {
+ notificationStorage.registerNewNotification(notification);
+ } else {
+ notificationStorage.deleteNotification(notification.spec.getId());
+ }
+ sendMessage(new GncsNotificationSourceMessage(event, notification.flags, notification.category, notificationStorage.getCountInCategory(notification.category), notification.spec.getId()).packet);
+ }
+
+ private void listFiles(int filterType) {
+ LOG.info("Requesting file list (filter={})", filterType);
+ sendMessage(new DirectoryFileFilterRequestMessage(filterType).packet);
+ }
+
+ private void downloadFile(int fileIndex) {
+ LOG.info("Requesting download of file {}", fileIndex);
+ fileDownloadQueue.addToDownloadQueue(fileIndex, 0);
+ }
+
+ private void downloadGarminDeviceXml() {
+ LOG.info("Requesting Garmin device XML download");
+ fileDownloadQueue.addToDownloadQueue(VivomoveConstants.GARMIN_DEVICE_XML_FILE_INDEX, 0);
+ }
+
+ private void sendBatteryStatus(int batteryLevel) {
+ LOG.info("Sending battery status");
+ sendMessage(new BatteryStatusMessage(batteryLevel).packet);
+ }
+
+ private void doSync() {
+ LOG.info("Starting sync");
+ fitImporter = new FitDbImporter(getDevice());
+ // sendMessage(new SystemEventMessage(GarminSystemEventType.PAIR_START, 0).packet);
+ listFiles(DirectoryFileFilterRequestMessage.FILTER_NO_FILTER);
+ // TODO: Localization
+ GB.updateTransferNotification(null, "Downloading list of files", true, 0, getContext());
+ }
+
+ @Override
+ public boolean useAutoConnect() {
+ return true;
+ }
+
+ @Override
+ public void onNotification(NotificationSpec notificationSpec) {
+ if (notificationSubscription) {
+ sendNotification(AncsEvent.NOTIFICATION_ADDED, new NotificationData(notificationSpec));
+ } else {
+ LOG.debug("No notification subscription is active, ignoring notification");
+ }
+ }
+
+ @Override
+ public void onDeleteNotification(int id) {
+ final NotificationData notificationData = notificationStorage.retrieveNotification(id);
+ if (notificationData != null) {
+ sendNotification(AncsEvent.NOTIFICATION_REMOVED, notificationData);
+ }
+ }
+
+ @Override
+ public void onSetTime() {
+ sendCurrentTime();
+ }
+
+ @Override
+ public void onSetMusicInfo(MusicSpec musicSpec) {
+ sendMessage(new MusicControlEntityUpdateMessage(
+ new AmsEntityAttribute(AmsEntity.TRACK, AmsEntityAttribute.TRACK_ATTRIBUTE_ARTIST, 0, musicSpec.artist),
+ new AmsEntityAttribute(AmsEntity.TRACK, AmsEntityAttribute.TRACK_ATTRIBUTE_ALBUM, 0, musicSpec.album),
+ new AmsEntityAttribute(AmsEntity.TRACK, AmsEntityAttribute.TRACK_ATTRIBUTE_TITLE, 0, musicSpec.track),
+ new AmsEntityAttribute(AmsEntity.TRACK, AmsEntityAttribute.TRACK_ATTRIBUTE_DURATION, 0, String.valueOf(musicSpec.duration))
+ ).packet);
+ }
+
+ @Override
+ public void onEnableRealtimeSteps(boolean enable) {
+ communicator.enableRealtimeSteps(enable);
+ }
+
+ @Override
+ public void onFetchRecordedData(int dataTypes) {
+ doSync();
+ }
+
+ @Override
+ public void onReset(int flags) {
+ switch (flags) {
+ case GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET:
+ LOG.warn("Requesting factory reset");
+ sendMessage(new SystemEventMessage(GarminSystemEventType.FACTORY_RESET, 1).packet);
+ break;
+
+ default:
+ GB.toast(getContext(), "This kind of reset not supported for this device", Toast.LENGTH_LONG, GB.ERROR);
+ break;
+ }
+ }
+
+ @Override
+ public void onEnableRealtimeHeartRateMeasurement(boolean enable) {
+ communicator.enableRealtimeHeartRate(enable);
+ }
+
+ @Override
+ public void onFindDevice(boolean start) {
+ if (start) {
+ sendProtobufRequest(
+ GdiSmartProto.Smart.newBuilder()
+ .setFindMyWatchService(
+ GdiFindMyWatch.FindMyWatchService.newBuilder()
+ .setFindRequest(
+ GdiFindMyWatch.FindMyWatchService.FindMyWatchRequest.newBuilder()
+ .setTimeout(60)
+ )
+ )
+ .build()
+ .toByteArray());
+ } else {
+ sendProtobufRequest(
+ GdiSmartProto.Smart.newBuilder()
+ .setFindMyWatchService(
+ GdiFindMyWatch.FindMyWatchService.newBuilder()
+ .setCancelRequest(
+ GdiFindMyWatch.FindMyWatchService.FindMyWatchCancelRequest.newBuilder()
+ )
+ )
+ .build()
+ .toByteArray());
+ }
+ }
+
+ private void updateDeviceSettings() {
+ final FitMessage deviceSettingsMessage = new FitMessage(FitMessageDefinitions.DEFINITION_DEVICE_SETTINGS);
+ deviceSettingsMessage.setField("bluetooth_connection_alerts_enabled", 0);
+ deviceSettingsMessage.setField("auto_lock_enabled", 0);
+ deviceSettingsMessage.setField("activity_tracker_enabled", 1);
+ deviceSettingsMessage.setField("alarm_time", 0);
+ deviceSettingsMessage.setField("ble_auto_upload_enabled", 1);
+ deviceSettingsMessage.setField("autosync_min_steps", 1000);
+ deviceSettingsMessage.setField("vibration_intensity", 2);
+ deviceSettingsMessage.setField("screen_timeout", 0);
+ deviceSettingsMessage.setField("mounting_side", 1);
+ deviceSettingsMessage.setField("phone_notification_activity_filter", 0);
+ deviceSettingsMessage.setField("auto_goal_enabled", 1);
+ deviceSettingsMessage.setField("autosync_min_time", 60);
+ deviceSettingsMessage.setField("glance_mode_layout", 0);
+ deviceSettingsMessage.setField("time_offset", 7200);
+ deviceSettingsMessage.setField("phone_notification_default_filter", 0);
+ deviceSettingsMessage.setField("alarm_mode", -1);
+ deviceSettingsMessage.setField("backlight_timeout", 5);
+ deviceSettingsMessage.setField("sedentary_hr_alert_threshold", 100);
+ deviceSettingsMessage.setField("backlight_brightness", 0);
+ deviceSettingsMessage.setField("time_zone", 254);
+ deviceSettingsMessage.setField("sedentary_hr_alert_state", 0);
+ deviceSettingsMessage.setField("auto_activity_start_enabled", 0);
+ deviceSettingsMessage.setField("alarm_days", 0);
+ deviceSettingsMessage.setField("default_page", 1);
+ deviceSettingsMessage.setField("message_tones_enabled", 2);
+ deviceSettingsMessage.setField("key_tones_enabled", 2);
+ deviceSettingsMessage.setField("date_mode", 0);
+ deviceSettingsMessage.setField("backlight_gesture", 1);
+ deviceSettingsMessage.setField("backlight_mode", 3);
+ deviceSettingsMessage.setField("move_alert_enabled", 1);
+ deviceSettingsMessage.setField("sleep_do_not_disturb_enabled", 0);
+ deviceSettingsMessage.setField("display_orientation", 2);
+ deviceSettingsMessage.setField("time_mode", 1);
+ deviceSettingsMessage.setField("pages_enabled", 127);
+ deviceSettingsMessage.setField("smart_notification_display_orientation", 0);
+ deviceSettingsMessage.setField("display_steps_goal_enabled", 1);
+ sendMessage(new FitDataMessage(deviceSettingsMessage).packet);
+ }
+
+ private boolean foreground;
+
+ @Override
+ public void onSendWeather(WeatherSpec weatherSpec) {
+ sendWeatherConditions(weatherSpec);
+ }
+
+ private final Map filesToDownload = new ConcurrentHashMap<>();
+ private long totalDownloadSize;
+ private long lastTransferNotificationTimestamp;
+
+ private DownloadedFitFile findDownloadedFitFile(DaoSession session, Device device, User user, int fileNumber, int fileDataType, int fileSubType) {
+ final DownloadedFitFileDao fileDao = session.getDownloadedFitFileDao();
+ final Query query = fileDao.queryBuilder()
+ .where(
+ DownloadedFitFileDao.Properties.DeviceId.eq(device.getId()),
+ DownloadedFitFileDao.Properties.UserId.eq(user.getId()),
+ DownloadedFitFileDao.Properties.FileNumber.eq(fileNumber),
+ DownloadedFitFileDao.Properties.FileDataType.eq(fileDataType),
+ DownloadedFitFileDao.Properties.FileSubType.eq(fileSubType)
+ )
+ .build();
+
+ final List files = query.list();
+ return files.size() > 0 ? files.get(0) : null;
+ }
+
+ @Override
+ public void onDirectoryDownloaded(DirectoryData directoryData) {
+ if (filesToDownload.size() != 0) {
+ throw new IllegalStateException("File download already in progress!");
+ }
+
+ long totalSize = 0;
+ try {
+ try (final DBHandler dbHandler = GBApplication.acquireDB()) {
+ final DaoSession session = dbHandler.getDaoSession();
+ final GBDevice gbDevice = getDevice();
+ final Device device = DBHelper.getDevice(gbDevice, session);
+ final User user = DBHelper.getUser(session);
+
+ for (final DirectoryEntry entry : directoryData.entries) {
+ LOG.info("File #{}: type {}/{} #{}, {}B, flags {}/{}, timestamp {}", entry.fileIndex, entry.fileDataType, entry.fileSubType, entry.fileNumber, entry.fileSize, entry.specificFlags, entry.fileFlags, entry.fileDate);
+ if (entry.fileIndex == 0) {
+ // ?
+ LOG.warn("File #0 reported?");
+ continue;
+ }
+
+ final long timestamp = entry.fileDate.getTime();
+ final DownloadedFitFile alreadyDownloadedFile = findDownloadedFitFile(session, device, user, entry.fileNumber, entry.fileDataType, entry.fileSubType);
+ if (alreadyDownloadedFile == null) {
+ LOG.debug("File not yet downloaded");
+ } else {
+ if (alreadyDownloadedFile.getFileTimestamp() == timestamp && alreadyDownloadedFile.getFileSize() == entry.fileSize) {
+ LOG.debug("File already downloaded, skipping");
+ continue;
+ } else {
+ LOG.info("File #{} modified after previous download, removing previous version and re-downloading", entry.fileIndex);
+ alreadyDownloadedFile.delete();
+ }
+ }
+
+ filesToDownload.put(entry.fileIndex, entry);
+ fileDownloadQueue.addToDownloadQueue(entry.fileIndex, entry.fileSize);
+ totalSize += entry.fileSize;
+ }
+ }
+ } catch (Exception e) {
+ LOG.error("Error storing data to DB", e);
+ }
+
+ totalDownloadSize = totalSize;
+ }
+
+ @Override
+ public void onFileDownloadComplete(int fileIndex, byte[] data) {
+ LOG.info("Downloaded file {}: {} bytes", fileIndex, data.length);
+ final DirectoryEntry downloadedDirectoryEntry = filesToDownload.get(fileIndex);
+ if (downloadedDirectoryEntry == null) {
+ LOG.warn("Unexpected file {} downloaded", fileIndex);
+ } else {
+ try (final DBHandler dbHandler = GBApplication.acquireDB()) {
+ final DaoSession session = dbHandler.getDaoSession();
+
+ final GBDevice gbDevice = getDevice();
+ final Device device = DBHelper.getDevice(gbDevice, session);
+ final User user = DBHelper.getUser(session);
+ final int ts = (int) (System.currentTimeMillis() / 1000);
+
+ final DownloadedFitFile downloadedFitFile = new DownloadedFitFile(null, ts, device.getId(), user.getId(), downloadedDirectoryEntry.fileNumber, downloadedDirectoryEntry.fileDataType, downloadedDirectoryEntry.fileSubType, downloadedDirectoryEntry.fileDate.getTime(), downloadedDirectoryEntry.specificFlags, downloadedDirectoryEntry.fileSize, STORE_FIT_FILES ? data : null);
+ session.getDownloadedFitFileDao().insert(downloadedFitFile);
+ } catch (Exception e) {
+ LOG.error("Error saving downloaded file to database", e);
+ }
+ }
+
+ if (fileIndex <= 0x8000) {
+ fitImporter.processFitFile(fitParser.parseFitFile(data));
+ } else {
+ LOG.debug("Not importing file {} as FIT", fileIndex);
+ }
+ }
+
+ @Override
+ public void onFileDownloadError(int fileIndex) {
+ LOG.error("Failed to download file {}", fileIndex);
+ }
+
+ @Override
+ public void onDownloadProgress(long remainingBytes) {
+ LOG.debug("{}B/{} remaining to download", remainingBytes, totalDownloadSize);
+ if (remainingBytes == 0) {
+ GB.updateTransferNotification(null, null, false, 100, getContext());
+ } else if (totalDownloadSize > 0) {
+ final long now = System.currentTimeMillis();
+ if (now - lastTransferNotificationTimestamp < 1000) {
+ // do not issue updates too often
+ return;
+ }
+ lastTransferNotificationTimestamp = now;
+ // TODO: Localization
+ GB.updateTransferNotification(null, "Downloading data", true, Math.round(100.0f * (totalDownloadSize - remainingBytes) / totalDownloadSize), getContext());
+ }
+ }
+
+ @Override
+ public void onAllDownloadsCompleted() {
+ LOG.info("All downloads completed");
+ GB.updateTransferNotification(null, null, false, 100, getContext());
+ sendMessage(new SystemEventMessage(GarminSystemEventType.SYNC_COMPLETE, 0).packet);
+ if (fitImporter != null) {
+ fitImporter.processData();
+ GB.signalActivityDataFinish();
+ }
+ fitImporter = null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntity.java
new file mode 100644
index 000000000..6d5ed25ac
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntity.java
@@ -0,0 +1,7 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ams;
+
+public enum AmsEntity {
+ PLAYER,
+ QUEUE,
+ TRACK
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntityAttribute.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntityAttribute.java
new file mode 100644
index 000000000..1ed0afc19
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntityAttribute.java
@@ -0,0 +1,42 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ams;
+
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageWriter;
+
+import java.nio.charset.StandardCharsets;
+
+public class AmsEntityAttribute {
+ public static final int PLAYER_ATTRIBUTE_NAME = 0;
+ public static final int PLAYER_ATTRIBUTE_PLAYBACK_INFO = 1;
+ public static final int PLAYER_ATTRIBUTE_VOLUME = 2;
+
+ public static final int QUEUE_ATTRIBUTE_INDEX = 0;
+ public static final int QUEUE_ATTRIBUTE_COUNT = 1;
+ public static final int QUEUE_ATTRIBUTE_SHUFFLE_MODE = 2;
+ public static final int QUEUE_ATTRIBUTE_REPEAT_MODE = 3;
+
+ public static final int TRACK_ATTRIBUTE_ARTIST = 0;
+ public static final int TRACK_ATTRIBUTE_ALBUM = 1;
+ public static final int TRACK_ATTRIBUTE_TITLE = 2;
+ public static final int TRACK_ATTRIBUTE_DURATION = 3;
+
+ public final AmsEntity entity;
+ public final int attributeID;
+ public final int updateFlags;
+ public final byte[] value;
+
+ public AmsEntityAttribute(AmsEntity entity, int attributeID, int updateFlags, String value) {
+ this.entity = entity;
+ this.attributeID = attributeID;
+ this.updateFlags = updateFlags;
+ this.value = value.getBytes(StandardCharsets.UTF_8);
+ if (this.value.length > 255) throw new IllegalArgumentException("Too long value");
+ }
+
+ public void writeToMessage(MessageWriter writer) {
+ writer.writeByte(entity.ordinal());
+ writer.writeByte(attributeID);
+ writer.writeByte(updateFlags);
+ writer.writeByte(value.length);
+ writer.writeBytes(value);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAction.java
new file mode 100644
index 000000000..b1ab5697f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAction.java
@@ -0,0 +1,6 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+public enum AncsAction {
+ POSITIVE,
+ NEGATIVE
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAndroidAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAndroidAction.java
new file mode 100644
index 000000000..cee177053
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAndroidAction.java
@@ -0,0 +1,32 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+import android.util.SparseArray;
+
+public enum AncsAndroidAction {
+ REPLY_TEXT_MESSAGE(94),
+ REPLY_INCOMING_CALL(95),
+ ACCEPT_INCOMING_CALL(96),
+ REJECT_INCOMING_CALL(97),
+ DISMISS_NOTIFICATION(98),
+ BLOCK_APPLICATION(99);
+
+ private static final SparseArray valueByCode;
+
+ public final int code;
+
+ AncsAndroidAction(int code) {
+ this.code = code;
+ }
+
+ static {
+ final AncsAndroidAction[] values = values();
+ valueByCode = new SparseArray<>(values.length);
+ for (AncsAndroidAction value : values) {
+ valueByCode.append(value.code, value);
+ }
+ }
+
+ public static AncsAndroidAction getByCode(int code) {
+ return valueByCode.get(code);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAppAttribute.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAppAttribute.java
new file mode 100644
index 000000000..7114e389e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAppAttribute.java
@@ -0,0 +1,5 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+public enum AncsAppAttribute {
+ DISPLAY_NAME
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttribute.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttribute.java
new file mode 100644
index 000000000..1ad038b30
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttribute.java
@@ -0,0 +1,49 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+import android.util.SparseArray;
+
+public enum 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 valueByCode;
+
+ public final int code;
+ public final boolean hasLengthParam;
+ public final boolean hasAdditionalParams;
+
+ AncsAttribute(int code) {
+ this(code, false, false);
+ }
+
+ AncsAttribute(int code, boolean hasLengthParam) {
+ this(code, hasLengthParam, false);
+ }
+
+ AncsAttribute(int code, boolean hasLengthParam, boolean hasAdditionalParams) {
+ this.code = code;
+ this.hasLengthParam = hasLengthParam;
+ this.hasAdditionalParams = hasAdditionalParams;
+ }
+
+ static {
+ final AncsAttribute[] values = values();
+ valueByCode = new SparseArray<>(values.length);
+ for (AncsAttribute value : values) {
+ valueByCode.append(value.code, value);
+ }
+ }
+
+ public static AncsAttribute getByCode(int code) {
+ return valueByCode.get(code);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttributeRequest.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttributeRequest.java
new file mode 100644
index 000000000..2e4418ad5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttributeRequest.java
@@ -0,0 +1,11 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+public class AncsAttributeRequest {
+ public final AncsAttribute attribute;
+ public final int maxLength;
+
+ public AncsAttributeRequest(AncsAttribute attribute, int maxLength) {
+ this.attribute = attribute;
+ this.maxLength = maxLength;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCategory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCategory.java
new file mode 100644
index 000000000..f926fd3ec
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCategory.java
@@ -0,0 +1,17 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+public enum AncsCategory {
+ OTHER,
+ INCOMING_CALL,
+ MISSED_CALL,
+ VOICEMAIL,
+ SOCIAL,
+ SCHEDULE,
+ EMAIL,
+ NEWS,
+ HEALTH_AND_FITNESS,
+ BUSINESS_AND_FINANCE,
+ LOCATION,
+ ENTERTAINMENT,
+ SMS
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCommand.java
new file mode 100644
index 000000000..59078b939
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCommand.java
@@ -0,0 +1,31 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+import android.util.SparseArray;
+
+public enum AncsCommand {
+ GET_NOTIFICATION_ATTRIBUTES(0),
+ GET_APP_ATTRIBUTES(1),
+ PERFORM_NOTIFICATION_ACTION(2),
+ // Garmin extensions
+ PERFORM_ANDROID_ACTION(128);
+
+ private static final SparseArray valueByCode;
+
+ public final int code;
+
+ AncsCommand(int code) {
+ this.code = code;
+ }
+
+ static {
+ final AncsCommand[] values = values();
+ valueByCode = new SparseArray<>(values.length);
+ for (AncsCommand value : values) {
+ valueByCode.append(value.code, value);
+ }
+ }
+
+ public static AncsCommand getByCode(int code) {
+ return valueByCode.get(code);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsControlCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsControlCommand.java
new file mode 100644
index 000000000..d657fe82b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsControlCommand.java
@@ -0,0 +1,121 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageReader;
+import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+public abstract class AncsControlCommand {
+ private static final Logger LOG = LoggerFactory.getLogger(AncsControlCommand.class);
+
+ private static final AncsAppAttribute[] APP_ATTRIBUTE_VALUES = AncsAppAttribute.values();
+ private static final AncsAction[] ACTION_VALUES = AncsAction.values();
+
+ public final AncsCommand command;
+
+ protected AncsControlCommand(AncsCommand command) {
+ this.command = command;
+ }
+
+ public static AncsControlCommand parseCommand(byte[] buffer, int offset, int size) {
+ final int commandID = BinaryUtils.readByte(buffer, offset);
+ final AncsCommand command = AncsCommand.getByCode(commandID);
+ if (command == null) {
+ LOG.error("Unknown ANCS command {}", commandID);
+ return null;
+ }
+ switch (command) {
+ case GET_NOTIFICATION_ATTRIBUTES:
+ return createGetNotificationAttributesCommand(buffer, offset + 1, size - 1);
+ case GET_APP_ATTRIBUTES:
+ return createGetAppAttributesCommand(buffer, offset + 1, size - 1);
+ case PERFORM_NOTIFICATION_ACTION:
+ return createPerformNotificationAction(buffer, offset + 1, size - 1);
+ case PERFORM_ANDROID_ACTION:
+ return createPerformAndroidAction(buffer, offset + 1, size - 1);
+ default:
+ LOG.error("Unknown ANCS command {}", command);
+ return null;
+ }
+ }
+
+ private static AncsPerformAndroidAction createPerformAndroidAction(byte[] buffer, int offset, int size) {
+ final int notificationUID = BinaryUtils.readInt(buffer, offset);
+ final int actionID = BinaryUtils.readByte(buffer, offset + 4);
+ final AncsAndroidAction action = AncsAndroidAction.getByCode(actionID);
+ if (action == null) {
+ LOG.error("Unknown ANCS Android action {}", actionID);
+ return null;
+ }
+ int zero = ArrayUtils.indexOf((byte) 0, buffer, offset + 6, size - offset - 6);
+ if (zero < 0) zero = size;
+ final String text = new String(buffer, offset + 6, zero - offset - 6);
+
+ return new AncsPerformAndroidAction(notificationUID, action, text);
+ }
+
+ private static AncsPerformNotificationAction createPerformNotificationAction(byte[] buffer, int offset, int size) {
+ final MessageReader reader = new MessageReader(buffer, offset);
+ final int notificationUID = reader.readInt();
+ final int actionID = reader.readByte();
+ if (actionID < 0 || actionID >= ACTION_VALUES.length) {
+ LOG.error("Unknown ANCS action {}", actionID);
+ return null;
+ }
+ return new AncsPerformNotificationAction(notificationUID, ACTION_VALUES[actionID]);
+ }
+
+ private static AncsGetAppAttributesCommand createGetAppAttributesCommand(byte[] buffer, int offset, int size) {
+ int zero = ArrayUtils.indexOf((byte) 0, buffer, offset, size - offset);
+ if (zero < 0) zero = size;
+ final String appIdentifier = new String(buffer, offset, zero - offset, StandardCharsets.UTF_8);
+ final int attributeCount = size - (zero - offset);
+ final List requestedAttributes = new ArrayList<>(attributeCount);
+ for (int i = 0; i < attributeCount; ++i) {
+ final int attributeID = BinaryUtils.readByte(buffer, zero + 1 + i);
+ if (attributeID < 0 || attributeID >= APP_ATTRIBUTE_VALUES.length) {
+ LOG.error("Unknown ANCS app attribute {}", attributeID);
+ return null;
+ }
+ final AncsAppAttribute attribute = APP_ATTRIBUTE_VALUES[attributeID];
+ requestedAttributes.add(attribute);
+ }
+ return new AncsGetAppAttributesCommand(appIdentifier, requestedAttributes);
+ }
+
+ private static AncsGetNotificationAttributeCommand createGetNotificationAttributesCommand(byte[] buffer, int offset, int size) {
+ final MessageReader reader = new MessageReader(buffer, offset);
+ final int notificationUID = reader.readInt();
+ int pos = 4;
+ final List attributes = new ArrayList<>(size);
+ while (pos < size) {
+ final int attributeID = reader.readByte();
+ ++pos;
+ final AncsAttribute attribute = AncsAttribute.getByCode(attributeID);
+ if (attribute == null) {
+ LOG.error("Unknown ANCS attribute {}", attributeID);
+ return null;
+ }
+ final int maxLength;
+ if (attribute.hasLengthParam) {
+ maxLength = reader.readShort();
+ pos += 2;
+ } else if (attribute.hasAdditionalParams) {
+ maxLength = reader.readByte();
+ // TODO: What is this??
+ reader.readByte();
+ reader.readByte();
+ pos += 3;
+ } else {
+ maxLength = 0;
+ }
+ attributes.add(new AncsAttributeRequest(attribute, maxLength));
+ }
+ return new AncsGetNotificationAttributeCommand(notificationUID, attributes);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEvent.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEvent.java
new file mode 100644
index 000000000..c791dc536
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEvent.java
@@ -0,0 +1,7 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+public enum AncsEvent {
+ NOTIFICATION_ADDED,
+ NOTIFICATION_MODIFIED,
+ NOTIFICATION_REMOVED
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEventFlag.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEventFlag.java
new file mode 100644
index 000000000..6fbf68f12
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEventFlag.java
@@ -0,0 +1,9 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+public enum AncsEventFlag {
+ SILENT,
+ IMPORTANT,
+ PRE_EXISTING,
+ POSITIVE_ACTION,
+ NEGATIVE_ACTION
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetAppAttributesCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetAppAttributesCommand.java
new file mode 100644
index 000000000..c7ac8d65e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetAppAttributesCommand.java
@@ -0,0 +1,14 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+import java.util.List;
+
+public class AncsGetAppAttributesCommand extends AncsControlCommand {
+ public final String appIdentifier;
+ public final List requestedAttributes;
+
+ public AncsGetAppAttributesCommand(String appIdentifier, List requestedAttributes) {
+ super(AncsCommand.GET_APP_ATTRIBUTES);
+ this.appIdentifier = appIdentifier;
+ this.requestedAttributes = requestedAttributes;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributeCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributeCommand.java
new file mode 100644
index 000000000..43c01a4aa
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributeCommand.java
@@ -0,0 +1,14 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+import java.util.List;
+
+public class AncsGetNotificationAttributeCommand extends AncsControlCommand {
+ public final int notificationUID;
+ public final List attributes;
+
+ public AncsGetNotificationAttributeCommand(int notificationUID, List attributes) {
+ super(AncsCommand.GET_NOTIFICATION_ATTRIBUTES);
+ this.notificationUID = notificationUID;
+ this.attributes = attributes;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributesResponse.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributesResponse.java
new file mode 100644
index 000000000..7321361f3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributesResponse.java
@@ -0,0 +1,23 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageWriter;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+public class AncsGetNotificationAttributesResponse {
+ public final byte[] packet;
+
+ public AncsGetNotificationAttributesResponse(int notificationUID, Map attributes) {
+ final MessageWriter messageWriter = new MessageWriter();
+ messageWriter.writeByte(AncsCommand.GET_NOTIFICATION_ATTRIBUTES.code);
+ messageWriter.writeInt(notificationUID);
+ for(Map.Entry attribute : attributes.entrySet()) {
+ messageWriter.writeByte(attribute.getKey().code);
+ final byte[] bytes = attribute.getValue().getBytes(StandardCharsets.UTF_8);
+ messageWriter.writeShort(bytes.length);
+ messageWriter.writeBytes(bytes);
+ }
+ this.packet = messageWriter.getBytes();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformAndroidAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformAndroidAction.java
new file mode 100644
index 000000000..af58e703f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformAndroidAction.java
@@ -0,0 +1,14 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+public class AncsPerformAndroidAction extends AncsControlCommand {
+ public final int notificationUID;
+ public final AncsAndroidAction action;
+ public final String text;
+
+ public AncsPerformAndroidAction(int notificationUID, AncsAndroidAction action, String text) {
+ super(AncsCommand.PERFORM_ANDROID_ACTION);
+ this.notificationUID = notificationUID;
+ this.action = action;
+ this.text = text;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformNotificationAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformNotificationAction.java
new file mode 100644
index 000000000..487213c5e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformNotificationAction.java
@@ -0,0 +1,12 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+public class AncsPerformNotificationAction extends AncsControlCommand {
+ public final int notificationUID;
+ public final AncsAction action;
+
+ public AncsPerformNotificationAction(int notificationUID, AncsAction action) {
+ super(AncsCommand.PERFORM_NOTIFICATION_ACTION);
+ this.notificationUID = notificationUID;
+ this.action = action;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/GncsDataSourceQueue.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/GncsDataSourceQueue.java
new file mode 100644
index 000000000..1b6f6035e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/GncsDataSourceQueue.java
@@ -0,0 +1,97 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs;
+
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.VivomoveHrCommunicator;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GncsDataSourceMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GncsDataSourceResponseMessage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.LinkedList;
+import java.util.Queue;
+
+public class GncsDataSourceQueue {
+ private static final Logger LOG = LoggerFactory.getLogger(GncsDataSourceQueue.class);
+
+ private final VivomoveHrCommunicator communicator;
+ private final int maxPacketSize;
+ private final Queue queue = new LinkedList<>();
+
+ private byte[] currentPacket;
+ private int currentDataOffset;
+ private int lastSentSize;
+
+ public GncsDataSourceQueue(VivomoveHrCommunicator communicator, int maxPacketSize) {
+ this.communicator = communicator;
+ this.maxPacketSize = maxPacketSize;
+ }
+
+ public void addToQueue(byte[] packet) {
+ queue.add(packet);
+ checkStartUpload();
+ }
+
+ public void responseReceived(GncsDataSourceResponseMessage responseMessage) {
+ if (currentPacket == null) {
+ LOG.error("Unexpected GNCS data source response, no current packet");
+ return;
+ }
+ switch (responseMessage.response) {
+ case GncsDataSourceResponseMessage.RESPONSE_TRANSFER_SUCCESSFUL:
+ LOG.debug("Confirmed {}B@{} GNCS transfer", lastSentSize, currentDataOffset);
+ currentDataOffset += lastSentSize;
+ if (currentDataOffset >= currentPacket.length) {
+ LOG.debug("ANCS packet transfer done");
+ currentPacket = null;
+ checkStartUpload();
+ } else {
+ sendNextMessage();
+ }
+ break;
+
+ case GncsDataSourceResponseMessage.RESPONSE_RESEND_LAST_DATA_PACKET:
+ LOG.info("Received RESEND_LAST_DATA_PACKET GNCS response");
+ sendNextMessage();
+ break;
+
+ case GncsDataSourceResponseMessage.RESPONSE_ABORT_REQUEST:
+ LOG.info("Received RESPONSE_ABORT_REQUEST GNCS response");
+ currentPacket = null;
+ checkStartUpload();
+ break;
+
+ case GncsDataSourceResponseMessage.RESPONSE_ERROR_CRC_MISMATCH:
+ case GncsDataSourceResponseMessage.RESPONSE_ERROR_DATA_OFFSET_MISMATCH:
+ default:
+ LOG.error("Received {} GNCS response", responseMessage.response);
+ currentPacket = null;
+ checkStartUpload();
+ break;
+ }
+ }
+
+ private void checkStartUpload() {
+ if (currentPacket != null) {
+ LOG.debug("Another upload is still running");
+ return;
+ }
+ if (queue.isEmpty()) {
+ LOG.debug("Nothing in queue");
+ return;
+ }
+ startNextUpload();
+ }
+
+ private void startNextUpload() {
+ currentPacket = queue.remove();
+ currentDataOffset = 0;
+ LOG.debug("Sending {}B ANCS data", currentPacket.length);
+ sendNextMessage();
+ }
+
+ private void sendNextMessage() {
+ final int remainingSize = currentPacket.length - currentDataOffset;
+ final int availableSize = Math.min(remainingSize, maxPacketSize);
+ communicator.sendMessage(new GncsDataSourceMessage(currentPacket, currentDataOffset, Math.min(remainingSize, maxPacketSize)).packet);
+ lastSentSize = availableSize;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryData.java
new file mode 100644
index 000000000..e5df6a211
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryData.java
@@ -0,0 +1,38 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads;
+
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.GarminTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageReader;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+public class DirectoryData {
+ public final List entries;
+
+ public DirectoryData(List entries) {
+ this.entries = entries;
+ }
+
+ public static DirectoryData parse(byte[] bytes) {
+ int size = bytes.length;
+ if ((size % 16) != 0) throw new IllegalArgumentException("Invalid directory data length");
+ int count = (size - 16) / 16;
+ final MessageReader reader = new MessageReader(bytes, 16);
+ final List entries = new ArrayList<>(count);
+ for (int i = 0; i < count; ++i) {
+ final int fileIndex = reader.readShort();
+ final int fileDataType = reader.readByte();
+ final int fileSubType = reader.readByte();
+ final int fileNumber = reader.readShort();
+ final int specificFlags = reader.readByte();
+ final int fileFlags = reader.readByte();
+ final int fileSize = reader.readInt();
+ final Date fileDate = new Date(GarminTimeUtils.garminTimestampToJavaMillis(reader.readInt()));
+
+ entries.add(new DirectoryEntry(fileIndex, fileDataType, fileSubType, fileNumber, specificFlags, fileFlags, fileSize, fileDate));
+ }
+
+ return new DirectoryData(entries);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryEntry.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryEntry.java
new file mode 100644
index 000000000..01a52af59
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryEntry.java
@@ -0,0 +1,25 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads;
+
+import java.util.Date;
+
+public class DirectoryEntry {
+ public final int fileIndex;
+ public final int fileDataType;
+ public final int fileSubType;
+ public final int fileNumber;
+ public final int specificFlags;
+ public final int fileFlags;
+ public final int fileSize;
+ public final Date fileDate;
+
+ public DirectoryEntry(int fileIndex, int fileDataType, int fileSubType, int fileNumber, int specificFlags, int fileFlags, int fileSize, Date fileDate) {
+ this.fileIndex = fileIndex;
+ this.fileDataType = fileDataType;
+ this.fileSubType = fileSubType;
+ this.fileNumber = fileNumber;
+ this.specificFlags = specificFlags;
+ this.fileFlags = fileFlags;
+ this.fileSize = fileSize;
+ this.fileDate = fileDate;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadListener.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadListener.java
new file mode 100644
index 000000000..9e70aac37
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadListener.java
@@ -0,0 +1,9 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads;
+
+public interface FileDownloadListener {
+ void onDirectoryDownloaded(DirectoryData directoryData);
+ void onFileDownloadComplete(int fileIndex, byte[] data);
+ void onFileDownloadError(int fileIndex);
+ void onDownloadProgress(long remainingBytes);
+ void onAllDownloadsCompleted();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadQueue.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadQueue.java
new file mode 100644
index 000000000..63538bad9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadQueue.java
@@ -0,0 +1,172 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.VivomoveHrCommunicator;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DownloadRequestMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DownloadRequestResponseMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FileTransferDataMessage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FileTransferDataResponseMessage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.Queue;
+import java.util.Set;
+
+public class FileDownloadQueue {
+ private static final Logger LOG = LoggerFactory.getLogger(FileDownloadQueue.class);
+
+ private final VivomoveHrCommunicator communicator;
+ private final FileDownloadListener listener;
+
+ private final Queue queue = new LinkedList<>();
+ private final Set queuedFileIndices = new HashSet<>();
+
+ private QueueItem currentlyDownloadingItem;
+ private int currentCrc;
+ private long totalRemainingBytes;
+
+ public FileDownloadQueue(VivomoveHrCommunicator communicator, FileDownloadListener listener) {
+ this.communicator = communicator;
+ this.listener = listener;
+ }
+
+ public void addToDownloadQueue(int fileIndex, int dataSize) {
+ if (queuedFileIndices.contains(fileIndex)) {
+ LOG.debug("Ignoring download request of {}, already in queue", fileIndex);
+ return;
+ }
+ queue.add(new QueueItem(fileIndex, dataSize));
+ queuedFileIndices.add(fileIndex);
+ totalRemainingBytes += dataSize;
+ checkRequestNextDownload();
+ }
+
+ public void cancelAllDownloads() {
+ queue.clear();
+ currentlyDownloadingItem = null;
+ communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_ABORT_DOWNLOAD_REQUEST, 0).packet);
+ }
+
+ private boolean checkRequestNextDownload() {
+ if (currentlyDownloadingItem != null) {
+ LOG.debug("Another download is pending");
+ return false;
+ }
+ if (queue.isEmpty()) {
+ LOG.debug("No download in queue");
+ return true;
+ }
+ requestNextDownload();
+ return false;
+ }
+
+ private void requestNextDownload() {
+ currentlyDownloadingItem = queue.remove();
+ currentCrc = 0;
+ final int fileIndex = currentlyDownloadingItem.fileIndex;
+ LOG.info("Requesting download of {} ({} B)", fileIndex, currentlyDownloadingItem.dataSize);
+ queuedFileIndices.remove(fileIndex);
+ communicator.sendMessage(new DownloadRequestMessage(fileIndex, 0, DownloadRequestMessage.REQUEST_NEW_TRANSFER, 0, 0).packet);
+ }
+
+ public void onDownloadRequestResponse(DownloadRequestResponseMessage responseMessage) {
+ if (currentlyDownloadingItem == null) {
+ LOG.error("Download request response arrived, but nothing is being downloaded");
+ return;
+ }
+
+ if (responseMessage.status == VivomoveConstants.STATUS_ACK && responseMessage.response == DownloadRequestResponseMessage.RESPONSE_DOWNLOAD_REQUEST_OKAY) {
+ LOG.info("Received response for download request of {}: {}/{}, {}B", currentlyDownloadingItem.fileIndex, responseMessage.status, responseMessage.response, responseMessage.fileSize);
+ totalRemainingBytes += responseMessage.fileSize - currentlyDownloadingItem.dataSize;
+ currentlyDownloadingItem.setDataSize(responseMessage.fileSize);
+ } else {
+ LOG.error("Received error response for download request of {}: {}/{}", currentlyDownloadingItem.fileIndex, responseMessage.status, responseMessage.response);
+ listener.onFileDownloadError(currentlyDownloadingItem.fileIndex);
+ totalRemainingBytes -= currentlyDownloadingItem.dataSize;
+ currentlyDownloadingItem = null;
+ checkRequestNextDownload();
+ }
+ }
+
+ public void onFileTransferData(FileTransferDataMessage dataMessage) {
+ final QueueItem currentlyDownloadingItem = this.currentlyDownloadingItem;
+ if (currentlyDownloadingItem == null) {
+ LOG.error("Download request response arrived, but nothing is being downloaded");
+ communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_ABORT_DOWNLOAD_REQUEST, 0).packet);
+ return;
+ }
+
+ if (dataMessage.dataOffset < currentlyDownloadingItem.dataOffset) {
+ LOG.warn("Ignoring repeated transfer at offset {} of #{}", dataMessage.dataOffset, currentlyDownloadingItem.fileIndex);
+ communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_ERROR_DATA_OFFSET_MISMATCH, currentlyDownloadingItem.dataOffset).packet);
+ return;
+ }
+ if (dataMessage.dataOffset > currentlyDownloadingItem.dataOffset) {
+ LOG.warn("Missing data at offset {} when received data at offset {} of #{}", currentlyDownloadingItem.dataOffset, dataMessage.dataOffset, currentlyDownloadingItem.fileIndex);
+ communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_ERROR_DATA_OFFSET_MISMATCH, currentlyDownloadingItem.dataOffset).packet);
+ return;
+ }
+
+ final int dataCrc = ChecksumCalculator.computeCrc(currentCrc, dataMessage.data, 0, dataMessage.data.length);
+ if (dataCrc != dataMessage.crc) {
+ LOG.warn("Invalid CRC ({} vs {}) for {}B data @{} of {}", dataCrc, dataMessage.crc, dataMessage.data.length, dataMessage.dataOffset, currentlyDownloadingItem.fileIndex);
+ communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_ERROR_CRC_MISMATCH, currentlyDownloadingItem.dataOffset).packet);
+ return;
+ }
+ currentCrc = dataCrc;
+
+ LOG.info("Received {}B@{}/{} of {}", dataMessage.data.length, dataMessage.dataOffset, currentlyDownloadingItem.dataSize, currentlyDownloadingItem.fileIndex);
+ currentlyDownloadingItem.appendData(dataMessage.data);
+ communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_TRANSFER_SUCCESSFUL, currentlyDownloadingItem.dataOffset).packet);
+
+ totalRemainingBytes -= dataMessage.data.length;
+ listener.onDownloadProgress(totalRemainingBytes);
+
+ if (currentlyDownloadingItem.dataOffset >= currentlyDownloadingItem.dataSize) {
+ LOG.info("Transfer of file #{} complete, {}/{}B downloaded", currentlyDownloadingItem.fileIndex, currentlyDownloadingItem.dataOffset, currentlyDownloadingItem.dataSize);
+ this.currentlyDownloadingItem = null;
+ final boolean allDone = checkRequestNextDownload();
+ reportCompletedDownload(currentlyDownloadingItem);
+ if (allDone && isIdle()) listener.onAllDownloadsCompleted();
+ }
+ }
+
+ private boolean isIdle() {
+ return currentlyDownloadingItem == null;
+ }
+
+ private void reportCompletedDownload(QueueItem downloadedItem) {
+ if (downloadedItem.fileIndex == 0) {
+ final DirectoryData directoryData = DirectoryData.parse(downloadedItem.data);
+ listener.onDirectoryDownloaded(directoryData);
+ } else {
+ listener.onFileDownloadComplete(downloadedItem.fileIndex, downloadedItem.data);
+ }
+ }
+
+ private static class QueueItem {
+ public final int fileIndex;
+ public int dataSize;
+ public int dataOffset;
+ public byte[] data;
+
+ public QueueItem(int fileIndex, int dataSize) {
+ this.fileIndex = fileIndex;
+ this.dataSize = dataSize;
+ }
+
+ public void setDataSize(int dataSize) {
+ if (this.data != null) throw new IllegalStateException("Data size already set");
+ this.dataSize = dataSize;
+ this.data = new byte[dataSize];
+ }
+
+ public void appendData(byte[] data) {
+ System.arraycopy(data, 0, this.data, dataOffset, data.length);
+ dataOffset += data.length;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitBool.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitBool.java
new file mode 100644
index 000000000..d9fc62613
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitBool.java
@@ -0,0 +1,7 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit;
+
+public final class FitBool {
+ public static final int FALSE = 0;
+ public static final int TRUE = 1;
+ public static final int INVALID = 255;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitDbImporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitDbImporter.java
new file mode 100644
index 000000000..e8207dc5e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitDbImporter.java
@@ -0,0 +1,54 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveHrSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+public class FitDbImporter {
+ private static final Logger LOG = LoggerFactory.getLogger(FitDbImporter.class);
+
+ private final GBDevice gbDevice;
+ private final FitImporter fitImporter;
+
+ public FitDbImporter(GBDevice gbDevice) {
+ this.gbDevice = gbDevice;
+ fitImporter = new FitImporter();
+ }
+
+ public void processFitFile(List messages) {
+ try {
+ fitImporter.importFitData(messages);
+ } catch (Exception e) {
+ LOG.error("Error importing FIT data", e);
+ }
+ }
+
+ public void processData() {
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ final DaoSession session = dbHandler.getDaoSession();
+
+ final Device device = DBHelper.getDevice(gbDevice, session);
+ final User user = DBHelper.getUser(session);
+ final VivomoveHrSampleProvider provider = new VivomoveHrSampleProvider(gbDevice, session);
+
+ fitImporter.processImportedData(sample -> {
+ sample.setDevice(device);
+ sample.setUser(user);
+ sample.setProvider(provider);
+
+ provider.addGBActivitySample(sample);
+ });
+ } catch (Exception e) {
+ LOG.error("Error importing FIT data", e);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitFieldBaseType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitFieldBaseType.java
new file mode 100644
index 000000000..fa5d788bc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitFieldBaseType.java
@@ -0,0 +1,53 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit;
+
+import android.util.SparseArray;
+
+public enum FitFieldBaseType {
+ ENUM(0, 1, 0xFF),
+ SINT8(1, 1, 0x7F),
+ UINT8(2, 1, 0xFF),
+ SINT16(3, 2, 0x7FFF),
+ UINT16(4, 2, 0xFFFF),
+ SINT32(5, 4, 0x7FFFFFFF),
+ UINT32(6, 4, 0xFFFFFFFF),
+ STRING(7, 1, ""),
+ FLOAT32(8, 4, 0xFFFFFFFF),
+ FLOAT64(9, 8, 0xFFFFFFFFFFFFFFFFL),
+ UINT8Z(10, 1, 0),
+ UINT16Z(11, 2, 0),
+ UINT32Z(12, 4, 0),
+ BYTE(13, 1, 0xFF),
+ SINT64(14, 8, 0x7FFFFFFFFFFFFFFFL),
+ UINT64(15, 8, 0xFFFFFFFFFFFFFFFFL),
+ UINT64Z(16, 8, 0);
+
+ public final int typeNumber;
+ public final int size;
+ public final int typeID;
+ public final Object invalidValue;
+
+ private static final SparseArray typeForCode = new SparseArray<>(values().length);
+ private static final SparseArray typeForID = new SparseArray<>(values().length);
+
+ static {
+ for (FitFieldBaseType value : values()) {
+ typeForCode.append(value.typeNumber, value);
+ typeForID.append(value.typeID, value);
+ }
+ }
+
+ FitFieldBaseType(int typeNumber, int size, Object invalidValue) {
+ this.typeNumber = typeNumber;
+ this.size = size;
+ this.invalidValue = invalidValue;
+ this.typeID = size > 1 ? (typeNumber | 0x80) : typeNumber;
+ }
+
+ public static FitFieldBaseType decodeTypeID(int typeNumber) {
+ final FitFieldBaseType type = typeForID.get(typeNumber);
+ if (type == null) {
+ throw new IllegalArgumentException("Unknown type " + typeNumber);
+ }
+ return type;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImportProcessor.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImportProcessor.java
new file mode 100644
index 000000000..d9e7c68fa
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImportProcessor.java
@@ -0,0 +1,7 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.VivomoveHrActivitySample;
+
+interface FitImportProcessor {
+ void onSample(VivomoveHrActivitySample sample);
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImporter.java
new file mode 100644
index 000000000..07ecf0927
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImporter.java
@@ -0,0 +1,272 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit;
+
+import android.util.SparseIntArray;
+import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveHrSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.VivomoveHrActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.GarminTimeUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+public class FitImporter {
+ private static final int ACTIVITY_TYPE_ALL = -1;
+ private final SortedMap> eventsPerTimestamp = new TreeMap<>();
+
+ public void importFitData(List messages) {
+ boolean ohrEnabled = false;
+ int softwareVersion = -1;
+
+ int lastTimestamp = 0;
+ final SparseIntArray lastCycles = new SparseIntArray();
+
+ for (FitMessage message : messages) {
+ switch (message.definition.globalMessageID) {
+ case FitMessageDefinitions.FIT_MESSAGE_NUMBER_EVENT:
+ //message.getField();
+ break;
+
+ case FitMessageDefinitions.FIT_MESSAGE_NUMBER_SOFTWARE:
+ final Integer versionField = message.getIntegerField("version");
+ if (versionField != null) softwareVersion = versionField;
+ break;
+
+ case FitMessageDefinitions.FIT_MESSAGE_NUMBER_MONITORING_INFO:
+ lastTimestamp = message.getIntegerField("timestamp");
+ break;
+
+ case FitMessageDefinitions.FIT_MESSAGE_NUMBER_MONITORING:
+ lastTimestamp = processMonitoringMessage(message, ohrEnabled, lastTimestamp, lastCycles);
+ break;
+
+ case FitMessageDefinitions.FIT_MESSAGE_NUMBER_OHR_SETTINGS:
+ final Boolean isOhrEnabled = message.getBooleanField("enabled");
+ if (isOhrEnabled != null) ohrEnabled = isOhrEnabled;
+ break;
+
+ case FitMessageDefinitions.FIT_MESSAGE_NUMBER_SLEEP_LEVEL:
+ processSleepLevelMessage(message);
+ break;
+
+ case FitMessageDefinitions.FIT_MESSAGE_NUMBER_MONITORING_HR_DATA:
+ processHrDataMessage(message);
+ break;
+
+ case FitMessageDefinitions.FIT_MESSAGE_NUMBER_STRESS_LEVEL:
+ processStressLevelMessage(message);
+ break;
+
+ case FitMessageDefinitions.FIT_MESSAGE_NUMBER_MAX_MET_DATA:
+ processMaxMetDataMessage(message);
+ break;
+ }
+ }
+ }
+
+ public void processImportedData(FitImportProcessor processor) {
+ for (final Map.Entry> eventsForTimestamp : eventsPerTimestamp.entrySet()) {
+ final VivomoveHrActivitySample sample = new VivomoveHrActivitySample();
+ sample.setTimestamp(eventsForTimestamp.getKey());
+
+ sample.setRawKind(ActivitySample.NOT_MEASURED);
+ sample.setCaloriesBurnt(ActivitySample.NOT_MEASURED);
+ sample.setSteps(ActivitySample.NOT_MEASURED);
+ sample.setHeartRate(ActivitySample.NOT_MEASURED);
+ sample.setFloorsClimbed(ActivitySample.NOT_MEASURED);
+ sample.setRawIntensity(ActivitySample.NOT_MEASURED);
+
+ FitEvent.EventKind bestKind = FitEvent.EventKind.UNKNOWN;
+ float bestScore = Float.NEGATIVE_INFINITY;
+ for (final FitEvent event : eventsForTimestamp.getValue()) {
+ if (event.getHeartRate() > sample.getHeartRate()) {
+ sample.setHeartRate(event.getHeartRate());
+ }
+ if (event.getFloorsClimbed() > sample.getFloorsClimbed()) {
+ sample.setFloorsClimbed(event.getFloorsClimbed());
+ }
+
+ float score = 0;
+ if (event.getRawKind() > 0) score += 1;
+ if (event.getCaloriesBurnt() > 0) score += event.getCaloriesBurnt() * 10.0f;
+ if (event.getSteps() > 0) score += event.getSteps();
+ //if (event.getRawIntensity() > 0) score += 10.0f * event.getRawIntensity();
+ if (event.getKind().isBetterThan(bestKind) || (event.getKind() == bestKind && score > bestScore)) {
+// if (bestScore > Float.NEGATIVE_INFINITY && event.getKind() != FitEvent.EventKind.NOT_WORN) {
+// System.out.println(String.format(Locale.ROOT, "Replacing %s %d (%d cal, %d steps) with %s %d (%d cal, %d steps)", sample.getRawKind(), sample.getRawIntensity(), sample.getCaloriesBurnt(), sample.getSteps(), event.getRawKind(), event.getRawIntensity(), event.getCaloriesBurnt(), event.getSteps()));
+// }
+ bestScore = score;
+ bestKind = event.getKind();
+ sample.setRawKind(event.getRawKind());
+ sample.setCaloriesBurnt(event.getCaloriesBurnt());
+ sample.setSteps(event.getSteps());
+ sample.setRawIntensity(event.getRawIntensity());
+ }
+ }
+
+ if (sample.getHeartRate() == ActivitySample.NOT_MEASURED && ((sample.getRawKind() & VivomoveHrSampleProvider.RAW_TYPE_KIND_SLEEP) != 0)) {
+ sample.setRawKind(VivomoveHrSampleProvider.RAW_NOT_WORN);
+ sample.setRawIntensity(0);
+ }
+
+ processor.onSample(sample);
+ }
+ }
+
+ private void processSleepLevelMessage(FitMessage message) {
+ final Integer timestampFull = message.getIntegerField("timestamp");
+ final Integer sleepLevel = message.getIntegerField("sleep_level");
+
+ final int timestamp = GarminTimeUtils.garminTimestampToUnixTime(timestampFull);
+ final int rawIntensity = (4 - sleepLevel) * 40;
+ final int rawKind = VivomoveHrSampleProvider.RAW_TYPE_KIND_SLEEP | sleepLevel;
+
+ addEvent(new FitEvent(timestamp, FitEvent.EventKind.SLEEP, rawKind, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, rawIntensity));
+ }
+
+ private int processMonitoringMessage(FitMessage message, boolean ohrEnabled, int lastTimestamp, SparseIntArray lastCycles) {
+ final Integer activityType = message.getIntegerField("activity_type");
+ final Double activeCalories = message.getNumericField("active_calories");
+ final Integer intensity = message.getIntegerField("current_activity_type_intensity");
+ final Integer cycles = message.getIntegerField("cycles");
+ final Double heartRateMeasured = message.getNumericField("heart_rate");
+ final Integer timestampFull = message.getIntegerField("timestamp");
+ final Integer timestamp16 = message.getIntegerField("timestamp_16");
+ final Double activeTime = message.getNumericField("active_time");
+
+ final int activityTypeOrAll = activityType == null ? ACTIVITY_TYPE_ALL : activityType;
+ final int activityTypeOrDefault = activityType == null ? 0 : activityType;
+
+ final int lastDefaultCycleCount = lastCycles.get(ACTIVITY_TYPE_ALL);
+ final int lastCycleCount = Math.max(lastCycles.get(activityTypeOrAll), lastDefaultCycleCount);
+ final Integer currentCycles = cycles == null ? null : cycles < lastCycleCount ? cycles : cycles - lastCycleCount;
+ if (currentCycles != null) {
+ lastCycles.put(activityTypeOrDefault, cycles);
+ final int newAllCycles = Math.max(lastDefaultCycleCount, cycles);
+ if (newAllCycles != lastDefaultCycleCount) {
+ assert newAllCycles > lastDefaultCycleCount;
+ lastCycles.put(ACTIVITY_TYPE_ALL, newAllCycles);
+ }
+ }
+
+ if (timestampFull != null) {
+ lastTimestamp = timestampFull;
+ } else if (timestamp16 != null) {
+ lastTimestamp += (timestamp16 - (lastTimestamp & 0xFFFF)) & 0xFFFF;
+ } else {
+ // TODO: timestamp_min_8
+ throw new IllegalArgumentException("Unsupported timestamp");
+ }
+
+ final int timestamp = GarminTimeUtils.garminTimestampToUnixTime(lastTimestamp);
+ final int rawKind, caloriesBurnt, floorsClimbed, heartRate, steps, rawIntensity;
+ final FitEvent.EventKind eventKind;
+
+ caloriesBurnt = activeCalories == null ? ActivitySample.NOT_MEASURED : (int) Math.round(activeCalories);
+ floorsClimbed = ActivitySample.NOT_MEASURED;
+ heartRate = ohrEnabled && heartRateMeasured != null && heartRateMeasured > 0 ? (int) Math.round(heartRateMeasured) : ActivitySample.NOT_MEASURED;
+ steps = currentCycles == null ? ActivitySample.NOT_MEASURED : currentCycles;
+ rawIntensity = intensity == null ? 0 : intensity;
+ rawKind = VivomoveHrSampleProvider.RAW_TYPE_KIND_ACTIVITY | activityTypeOrDefault;
+ eventKind = steps != ActivitySample.NOT_MEASURED || rawIntensity > 0 || activityTypeOrDefault > 0 ? FitEvent.EventKind.ACTIVITY : FitEvent.EventKind.WORN;
+
+ if (rawKind != ActivitySample.NOT_MEASURED
+ || caloriesBurnt != ActivitySample.NOT_MEASURED
+ || floorsClimbed != ActivitySample.NOT_MEASURED
+ || heartRate != ActivitySample.NOT_MEASURED
+ || steps != ActivitySample.NOT_MEASURED
+ || rawIntensity != ActivitySample.NOT_MEASURED) {
+
+ addEvent(new FitEvent(timestamp, eventKind, rawKind, caloriesBurnt, floorsClimbed, heartRate, steps, rawIntensity));
+ } else {
+ addEvent(new FitEvent(timestamp, FitEvent.EventKind.NOT_WORN, VivomoveHrSampleProvider.RAW_NOT_WORN, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED));
+ }
+ return lastTimestamp;
+ }
+
+ private void processHrDataMessage(FitMessage message) {
+ }
+
+ private void processStressLevelMessage(FitMessage message) {
+ }
+
+ private void processMaxMetDataMessage(FitMessage message) {
+ }
+
+ private void addEvent(FitEvent event) {
+ List eventsForTimestamp = eventsPerTimestamp.get(event.getTimestamp());
+ if (eventsForTimestamp == null) {
+ eventsForTimestamp = new ArrayList<>();
+ eventsPerTimestamp.put(event.getTimestamp(), eventsForTimestamp);
+ }
+ eventsForTimestamp.add(event);
+ }
+
+ private static class FitEvent {
+ private final int timestamp;
+ private final EventKind kind;
+ private final int rawKind;
+ private final int caloriesBurnt;
+ private final int floorsClimbed;
+ private final int heartRate;
+ private final int steps;
+ private final int rawIntensity;
+
+ private FitEvent(int timestamp, EventKind kind, int rawKind, int caloriesBurnt, int floorsClimbed, int heartRate, int steps, int rawIntensity) {
+ this.timestamp = timestamp;
+ this.kind = kind;
+ this.rawKind = rawKind;
+ this.caloriesBurnt = caloriesBurnt;
+ this.floorsClimbed = floorsClimbed;
+ this.heartRate = heartRate;
+ this.steps = steps;
+ this.rawIntensity = rawIntensity;
+ }
+
+ public int getTimestamp() {
+ return timestamp;
+ }
+
+ public EventKind getKind() {
+ return kind;
+ }
+
+ public int getRawKind() {
+ return rawKind;
+ }
+
+ public int getCaloriesBurnt() {
+ return caloriesBurnt;
+ }
+
+ public int getFloorsClimbed() {
+ return floorsClimbed;
+ }
+
+ public int getHeartRate() {
+ return heartRate;
+ }
+
+ public int getSteps() {
+ return steps;
+ }
+
+ public int getRawIntensity() {
+ return rawIntensity;
+ }
+
+ public enum EventKind {
+ UNKNOWN,
+ NOT_WORN,
+ WORN,
+ SLEEP,
+ ACTIVITY;
+
+ public boolean isBetterThan(EventKind other) {
+ return ordinal() > other.ordinal();
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalFieldDefinition.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalFieldDefinition.java
new file mode 100644
index 000000000..7a54fe6fb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalFieldDefinition.java
@@ -0,0 +1,13 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit;
+
+class FitLocalFieldDefinition {
+ public final FitMessageFieldDefinition globalDefinition;
+ public final int size;
+ public final FitFieldBaseType baseType;
+
+ FitLocalFieldDefinition(FitMessageFieldDefinition globalDefinition, int size, FitFieldBaseType baseType) {
+ this.globalDefinition = globalDefinition;
+ this.size = size;
+ this.baseType = baseType;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalMessageDefinition.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalMessageDefinition.java
new file mode 100644
index 000000000..ed29550e1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalMessageDefinition.java
@@ -0,0 +1,13 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit;
+
+import java.util.List;
+
+class FitLocalMessageDefinition {
+ public final FitMessageDefinition globalDefinition;
+ public final List fieldDefinitions;
+
+ FitLocalMessageDefinition(FitMessageDefinition globalDefinition, List fieldDefinitions) {
+ this.globalDefinition = globalDefinition;
+ this.fieldDefinitions = fieldDefinitions;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessage.java
new file mode 100644
index 000000000..d8f809570
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessage.java
@@ -0,0 +1,165 @@
+package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit;
+
+import android.util.SparseArray;
+import androidx.annotation.NonNull;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageWriter;
+
+import java.lang.reflect.Array;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+public class FitMessage {
+ public final FitMessageDefinition definition;
+ private final SparseArray