From 5fdc2b8fc6934e5e9869b4a15fc47fba638a107a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Mon, 15 Dec 2025 20:45:17 +0000 Subject: [PATCH] Garmin: Basic http web request/response parsing As per https://gadgetbridge.org/internals/specifics/garmin-protocol/#internal-structures --- .idea/dictionaries/t.xml | 1 + .../devices/garmin/http/GarminJson.java | 356 ++++++++++++++++++ .../garmin/http/GarminJsonException.java | 11 + .../devices/garmin/http/HttpHandler.java | 14 + .../main/proto/garmin/gdi_http_service.proto | 35 ++ .../devices/garmin/http/GarminJsonTest.java | 243 ++++++++++++ 6 files changed, 660 insertions(+) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminJson.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminJsonException.java create mode 100644 app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminJsonTest.java diff --git a/.idea/dictionaries/t.xml b/.idea/dictionaries/t.xml index 4366d93e72..55684f7f5d 100644 --- a/.idea/dictionaries/t.xml +++ b/.idea/dictionaries/t.xml @@ -148,6 +148,7 @@ shahrabani shimokawa shokz + sint skype slezak sophanimus diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminJson.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminJson.java new file mode 100644 index 0000000000..2eab27e7c2 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminJson.java @@ -0,0 +1,356 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.Map; +import java.util.Queue; +import java.util.Set; + +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class GarminJson { + private static final byte[] STRING_SECTION_MAGIC = {(byte) 0xab, (byte) 0xcd, (byte) 0xab, (byte) 0xcd}; + private static final byte[] DATA_SECTION_MAGIC = {(byte) 0xda, (byte) 0x7a, (byte) 0xda, (byte) 0x7a}; + + private static final byte TYPE_NULL = 0x00; + private static final byte TYPE_SINT32 = 0x01; + private static final byte TYPE_FLOAT = 0x02; + private static final byte TYPE_STRING = 0x03; + private static final byte TYPE_ARRAY = 0x05; + private static final byte TYPE_BOOL = 0x09; + private static final byte TYPE_MAP = 0x0b; + private static final byte TYPE_SINT64 = 0x0e; + private static final byte TYPE_DOUBLE = 0x0f; + + public static byte[] encode(final Object object) throws GarminJsonException { + try { + // First pass: collect all strings + Set strings = new LinkedHashSet<>(); + collectStrings(object, strings); + + // Build string section + final ByteArrayOutputStream stringSection = new ByteArrayOutputStream(); + int currentOffset = 0; + final Map finalOffsets = new LinkedHashMap<>(); + for (final String str : strings) { + finalOffsets.put(str, currentOffset); + final byte[] strBytes = str.getBytes(StandardCharsets.UTF_8); + final int length = strBytes.length + 1; // +1 for null terminator + stringSection.write((length >> 8) & 0xFF); + stringSection.write(length & 0xFF); + stringSection.write(strBytes); + stringSection.write(0x00); + currentOffset += 2 + strBytes.length + 1; + } + + // Build data section (breadth-first) + final ByteArrayOutputStream dataSection = new ByteArrayOutputStream(); + encodeValueBreadthFirst(object, dataSection, finalOffsets); + + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Write string section, if any + if (stringSection.size() > 0) { + output.write(STRING_SECTION_MAGIC); + writeUint32BE(output, stringSection.size()); + output.write(stringSection.toByteArray()); + } + + // Write data section + output.write(DATA_SECTION_MAGIC); + writeUint32BE(output, dataSection.size()); + output.write(dataSection.toByteArray()); + + return output.toByteArray(); + } catch (final IOException e) { + throw new GarminJsonException("Failed to encode GarminJson", e); + } + } + + private static void encodeValueBreadthFirst(final Object rootObj, + final ByteArrayOutputStream out, + final Map stringOffsets) throws GarminJsonException { + final Queue queue = new LinkedList<>(); + queue.add(rootObj); + + while (!queue.isEmpty()) { + final Object obj = queue.poll(); + + if (obj == null || obj instanceof JsonNull) { + out.write(TYPE_NULL); + } else if (obj instanceof JsonPrimitive jsonPrimitive) { + if (jsonPrimitive.isBoolean()) { + out.write(TYPE_BOOL); + out.write(jsonPrimitive.getAsBoolean() ? 0x01 : 0x00); + } else if (jsonPrimitive.isNumber()) { + final Number num = jsonPrimitive.getAsNumber(); + // Check the actual value to determine the appropriate type + // This handles LazilyParsedNumber and other Number implementations + final double doubleValue = num.doubleValue(); + final long longValue = num.longValue(); + + // Check if it's a floating point number + if (doubleValue != longValue || num instanceof Float || num instanceof Double) { + // It has a fractional part or is explicitly a float/double + if (num instanceof Float || (Math.abs(doubleValue) < Float.MAX_VALUE && doubleValue == (float) doubleValue)) { + out.write(TYPE_FLOAT); + writeFloat(out, num.floatValue()); + } else { + out.write(TYPE_DOUBLE); + writeDouble(out, num.doubleValue()); + } + } else { + // It's an integer value + if (longValue >= Integer.MIN_VALUE && longValue <= Integer.MAX_VALUE) { + out.write(TYPE_SINT32); + writeUint32BE(out, (int) longValue); + } else { + out.write(TYPE_SINT64); + writeUint64BE(out, longValue); + } + } + } else if (jsonPrimitive.isString()) { + out.write(TYPE_STRING); + final String str = jsonPrimitive.getAsString(); + final Integer offset = stringOffsets.get(str); + if (offset == null) { + throw new GarminJsonException("String not found in offset map: " + str); + } + writeUint32BE(out, offset); + } else { + throw new GarminJsonException("Unexpected json primitive: " + jsonPrimitive.getClass().getName()); + } + } else if (obj instanceof String str) { + out.write(TYPE_STRING); + final Integer offset = stringOffsets.get(str); + if (offset == null) { + throw new GarminJsonException("String not found in offset map: " + str); + } + writeUint32BE(out, offset); + } else if (obj instanceof JsonArray jsonArray) { + out.write(TYPE_ARRAY); + writeUint32BE(out, jsonArray.size()); + // Add array elements to queue for breadth-first processing + for (final JsonElement element : jsonArray) { + queue.add(element); + } + } else if (obj instanceof JsonObject jsonObj) { + out.write(TYPE_MAP); + writeUint32BE(out, jsonObj.size()); + // Add key-value pairs to queue for breadth-first processing + for (final Map.Entry entry : jsonObj.entrySet()) { + queue.add(entry.getKey()); + queue.add(entry.getValue()); + } + } else { + throw new GarminJsonException("Unsupported type: " + obj.getClass().getName()); + } + } + } + + public static Object decode(final byte[] bytes) throws GarminJsonException { + final ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); + + // Parse string section if present + final Map strings = new LinkedHashMap<>(); + final byte[] magic = new byte[4]; + buffer.get(magic); + + if (Arrays.equals(magic, STRING_SECTION_MAGIC)) { + final int stringSectionLength = buffer.getInt(); + final int stringSectionEnd = buffer.position() + stringSectionLength; + final int stringSectionStart = buffer.position(); + + while (buffer.position() < stringSectionEnd) { + final int stringStart = buffer.position() - stringSectionStart; + final int length = buffer.getShort() & 0xFFFF; + final byte[] strBytes = new byte[length - 1]; // -1 for null terminator + buffer.get(strBytes); + buffer.get(); // skip null terminator + final String str = new String(strBytes, StandardCharsets.UTF_8); + strings.put(stringStart, str); + } + + // Read data section magic + buffer.get(magic); + } + + if (!Arrays.equals(magic, DATA_SECTION_MAGIC)) { + throw new GarminJsonException("Expected data section magic, got " + GB.hexdump(magic)); + } + + final int dataSectionLength = buffer.getInt(); + if (buffer.position() + dataSectionLength != bytes.length) { + throw new GarminJsonException("Data section length does not match"); + } + + final Object rootObject = decodeValue(buffer, strings); + + // Process placeholders breadth-first using a queue + final Queue queue = new LinkedList<>(); + queue.add(rootObject); + + while (!queue.isEmpty()) { + Object current = queue.poll(); + + if (current instanceof MapPlaceholder mp) { + for (int i = 0; i < mp.size; i++) { + final Object key = decodeValue(buffer, strings); + final Object value = decodeValue(buffer, strings); + + // Unwrap placeholders to get the actual JSON object/array + Object actualValue = value; + if (value instanceof MapPlaceholder) { + actualValue = ((MapPlaceholder) value).obj; + queue.add(value); + } else if (value instanceof ArrayPlaceholder) { + actualValue = ((ArrayPlaceholder) value).array; + queue.add(value); + } + + mp.obj.add(String.valueOf(((JsonPrimitive) key).getAsString()), (JsonElement) actualValue); + } + } else if (current instanceof ArrayPlaceholder ap) { + for (int i = 0; i < ap.length; i++) { + Object value = decodeValue(buffer, strings); + + // Unwrap placeholders to get the actual JSON object/array + Object actualValue = value; + if (value instanceof MapPlaceholder) { + actualValue = ((MapPlaceholder) value).obj; + queue.add(value); + } else if (value instanceof ArrayPlaceholder) { + actualValue = ((ArrayPlaceholder) value).array; + queue.add(value); + } + + ap.array.add((JsonElement) actualValue); + } + } + } + + if (rootObject instanceof ArrayPlaceholder ap) { + return ap.array; + } else if (rootObject instanceof MapPlaceholder mp) { + return mp.obj; + } else if (rootObject instanceof JsonElement jsonElement) { + return jsonElement; + } else { + throw new GarminJsonException("Unexpected root object " + rootObject.getClass()); + } + } + + private static void collectStrings(final Object rootObj, final Set strings) { + final Queue queue = new LinkedList<>(); + queue.add(rootObj); + + while (!queue.isEmpty()) { + final Object obj = queue.poll(); + + if (obj instanceof JsonObject jsonObj) { + for (final Map.Entry entry : jsonObj.entrySet()) { + strings.add(entry.getKey()); + final JsonElement value = entry.getValue(); + if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { + strings.add(value.getAsString()); + } else if (value.isJsonObject() || value.isJsonArray()) { + queue.add(value); + } + } + } else if (obj instanceof JsonArray jsonArray) { + for (final JsonElement element : jsonArray) { + if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + strings.add(element.getAsString()); + } else if (element.isJsonObject() || element.isJsonArray()) { + queue.add(element); + } + } + } else if (obj instanceof String str) { + strings.add(str); + } + } + } + + private static Object decodeValue(final ByteBuffer buffer, final Map strings) throws GarminJsonException { + byte type = buffer.get(); + + switch (type) { + case TYPE_NULL: + return JsonNull.INSTANCE; + case TYPE_BOOL: + return new JsonPrimitive(buffer.get() != 0x00); + case TYPE_SINT32: + return new JsonPrimitive(buffer.getInt()); + case TYPE_SINT64: + return new JsonPrimitive(buffer.getLong()); + case TYPE_FLOAT: + return new JsonPrimitive(buffer.getFloat()); + case TYPE_DOUBLE: + return new JsonPrimitive(buffer.getDouble()); + case TYPE_STRING: + final int offset = buffer.getInt(); + final String str = strings.get(offset); + if (str == null) { + throw new GarminJsonException("String not found in offset map: " + offset); + } + return new JsonPrimitive(str); + case TYPE_ARRAY: + final int arrayLength = buffer.getInt(); + // Decoding is breadth-first - don't decode children yet + // return placeholder with the expected number of children + return new ArrayPlaceholder(new JsonArray(), arrayLength); + case TYPE_MAP: + final int mapSize = buffer.getInt(); + // Decoding is breadth-first - don't decode children yet + // return placeholder with the expected number of children + return new MapPlaceholder(new JsonObject(), mapSize); + default: + throw new GarminJsonException("Unknown type: 0x" + Integer.toHexString(type & 0xFF)); + } + } + + private static void writeUint32BE(final ByteArrayOutputStream out, final int value) { + out.write((value >> 24) & 0xFF); + out.write((value >> 16) & 0xFF); + out.write((value >> 8) & 0xFF); + out.write(value & 0xFF); + } + + private static void writeUint64BE(final ByteArrayOutputStream out, final long value) { + out.write((int) ((value >> 56) & 0xFF)); + out.write((int) ((value >> 48) & 0xFF)); + out.write((int) ((value >> 40) & 0xFF)); + out.write((int) ((value >> 32) & 0xFF)); + out.write((int) ((value >> 24) & 0xFF)); + out.write((int) ((value >> 16) & 0xFF)); + out.write((int) ((value >> 8) & 0xFF)); + out.write((int) (value & 0xFF)); + } + + private static void writeFloat(final ByteArrayOutputStream out, final float value) { + writeUint32BE(out, Float.floatToIntBits(value)); + } + + private static void writeDouble(final ByteArrayOutputStream out, final double value) { + writeUint64BE(out, Double.doubleToLongBits(value)); + } + + private record MapPlaceholder(JsonObject obj, int size) { + } + + private record ArrayPlaceholder(JsonArray array, int length) { + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminJsonException.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminJsonException.java new file mode 100644 index 0000000000..d556bcef56 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminJsonException.java @@ -0,0 +1,11 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http; + +public class GarminJsonException extends Exception { + public GarminJsonException(final String message) { + super(message); + } + + public GarminJsonException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/HttpHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/HttpHandler.java index d75feb85ac..d8cc4fad14 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/HttpHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/HttpHandler.java @@ -39,6 +39,14 @@ public class HttpHandler { .build(); } return null; + } else if (httpService.hasWebRequest()) { + final GdiHttpService.HttpService.WebResponse webResponse = handleWebRequest(httpService.getWebRequest()); + if (webResponse != null) { + return GdiHttpService.HttpService.newBuilder() + .setWebResponse(webResponse) + .build(); + } + return null; } LOG.warn("Unsupported http service request {}", httpService); @@ -147,4 +155,10 @@ public class HttpHandler { .addAllHeader(responseHeaders) .build(); } + + public GdiHttpService.HttpService.WebResponse handleWebRequest(final GdiHttpService.HttpService.WebRequest webRequest) { + LOG.debug("Got webRequest: {} - {}", webRequest.getMethod(), webRequest.getUrl()); + + return null; + } } diff --git a/app/src/main/proto/garmin/gdi_http_service.proto b/app/src/main/proto/garmin/gdi_http_service.proto index 4f8a48e48c..a0cac7c057 100644 --- a/app/src/main/proto/garmin/gdi_http_service.proto +++ b/app/src/main/proto/garmin/gdi_http_service.proto @@ -23,9 +23,44 @@ message HttpService { DATA_TRANSFER_ITEM_FAILURE = 400; } + enum ResponseType { + JSON = 0; + URL_ENCODED = 1; + PLAIN_TEXT = 2; + XML = 3; + } + + enum Version { + VERSION_1 = 0; + VERSION_2 = 1; + } + + optional WebRequest webRequest = 1; + optional WebResponse webResponse = 2; optional RawRequest rawRequest = 5; optional RawResponse rawResponse = 6; + message WebRequest { + required string url = 1; + optional Method method = 2; + optional bytes headers = 3; + optional bytes body = 4; + optional uint32 maxResponseLength = 5; + optional bool httpHeadersInResponse = 6 [default = true]; + optional bool compressResponseBody = 7 [default = false]; + optional ResponseType responseType = 8; + optional Version version = 9 [default = VERSION_1]; + } + + message WebResponse { + optional Status status = 1; + optional uint32 httpStatus = 2; + optional bytes body = 3; + optional bytes headers = 4; + optional bytes size = 5; + optional ResponseType responseType = 6; + } + message RawRequest { required string url = 1; optional Method method = 3; diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminJsonTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminJsonTest.java new file mode 100644 index 0000000000..fed00c2659 --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminJsonTest.java @@ -0,0 +1,243 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; + +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Test; + +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class GarminJsonTest { + private static final Gson GSON = new Gson(); + + final String example1json = """ + { + "data": { + "glanceTemplate": { + "template": "{{ states('sensor.solarnet_power_grid_import') }}%" + } + }, + "type": "render_template" + } + """; + + final String example1hex = "abcdabcd000000710005646174610000" + + "057479706500001072656e6465725f74" + + "656d706c61746500000f676c616e6365" + + "54656d706c61746500000974656d706c" + + "6174650000337b7b2073746174657328" + + "2773656e736f722e736f6c61726e6574" + + "5f706f7765725f677269645f696d706f" + + "72742729207d7d2500da7ada7a000000" + + "2d0b0000000203000000000b00000001" + + "0300000007030000000e03000000200b" + + "000000010300000031030000003c"; + + final String example2json = """ + { + "a": 1, + "b": { + "c": "d", + "e": "c", + "f": ["x", 1, { "123": "456","789": "100" }] + }, + "array": [1, "two", true], + "nested": { + "level1": { + "level2": { + "level3": "value" + }, + "level2_2": 42 + } + } + } + """; + + final String example2hex = "abcdabcd000000790006617272617900000262000002610000076e6573746564" + + "00000474776f000002660000026300000264000002650000076c6576656c3100" + + "0002780000096c6576656c325f320000076c6576656c32000004313233000004" + + "3435360000043738390000043130300000076c6576656c3300000676616c7565" + + "00da7ada7a000000a20b000000040300000000050000000303000000080b0000" + + "0003030000000c010000000103000000100b0000000101000000010300000019" + + "0901030000001f050000000303000000230300000027030000002b0300000023" + + "030000002f0b00000002030000003801000000010b00000002030000003c0100" + + "00002a03000000470b0000000103000000500300000056030000005c03000000" + + "6203000000680300000071"; + + @Test + public void testDecodeExample1() throws Exception { + final byte[] bytes = GB.hexStringToByteArray(example1hex); + final JsonObject result = (JsonObject) GarminJson.decode(bytes); + + Assert.assertEquals(2, result.size()); + Assert.assertTrue(result.has("data")); + Assert.assertTrue(result.has("type")); + + Assert.assertEquals("render_template", result.get("type").getAsString()); + + final JsonObject data = result.getAsJsonObject("data"); + Assert.assertEquals(1, data.size()); + Assert.assertTrue(data.has("glanceTemplate")); + + final JsonObject glanceTemplate = data.getAsJsonObject("glanceTemplate"); + Assert.assertEquals(1, glanceTemplate.size()); + Assert.assertTrue(glanceTemplate.has("template")); + Assert.assertEquals("{{ states('sensor.solarnet_power_grid_import') }}%", + glanceTemplate.get("template").getAsString()); + } + + @Test + public void testDecodeExample2() throws Exception { + final byte[] bytes = GB.hexStringToByteArray(example2hex); + final JsonObject result = (JsonObject) GarminJson.decode(bytes); + + // root + Assert.assertEquals(4, result.size()); + Assert.assertTrue(result.has("a")); + Assert.assertTrue(result.has("b")); + Assert.assertTrue(result.has("array")); + Assert.assertTrue(result.has("nested")); + + // "a" + Assert.assertEquals(1, result.get("a").getAsInt()); + + // "b" + final JsonObject b = result.getAsJsonObject("b"); + Assert.assertEquals(3, b.size()); + Assert.assertEquals("d", b.get("c").getAsString()); + Assert.assertEquals("c", b.get("e").getAsString()); + // "b.f" + final JsonArray f = b.getAsJsonArray("f"); + Assert.assertEquals(3, f.size()); + Assert.assertEquals("x", f.get(0).getAsString()); + Assert.assertEquals(1, f.get(1).getAsInt()); + final JsonObject fObj = f.get(2).getAsJsonObject(); + Assert.assertEquals(2, fObj.size()); + Assert.assertEquals("456", fObj.get("123").getAsString()); + Assert.assertEquals("100", fObj.get("789").getAsString()); + + // "array" + final JsonArray array = result.getAsJsonArray("array"); + Assert.assertEquals(3, array.size()); + Assert.assertEquals(1, array.get(0).getAsInt()); + Assert.assertEquals("two", array.get(1).getAsString()); + Assert.assertTrue(array.get(2).getAsBoolean()); + + // "nested" + final JsonObject nested = result.getAsJsonObject("nested"); + Assert.assertEquals(1, nested.size()); + final JsonObject level1 = nested.getAsJsonObject("level1"); + Assert.assertEquals(2, level1.size()); + Assert.assertEquals(42, level1.get("level2_2").getAsInt()); + final JsonObject level2 = level1.getAsJsonObject("level2"); + Assert.assertEquals(1, level2.size()); + Assert.assertEquals("value", level2.get("level3").getAsString()); + } + + @Test + public void testEncodeExample1() throws Exception { + final JsonElement object = GSON.fromJson(example1json, JsonElement.class); + final byte[] encoded = GarminJson.encode(object); + Assert.assertEquals( + example1hex, + GB.hexdump(encoded).toLowerCase() + ); + } + + @Test + @Ignore("Field order is not kept by gson, so this is failing..") + public void testEncodeExample2() throws Exception { + final JsonElement object = GSON.fromJson(example2json, JsonElement.class); + final byte[] encoded = GarminJson.encode(object); + Assert.assertEquals( + example2hex, + GB.hexdump(encoded).toLowerCase() + ); + } + + @Test + public void testEncodeDecodeAllTypes() throws Exception { + final JsonObject json = new JsonObject(); + // basic types + json.add("nullValue", JsonNull.INSTANCE); + json.addProperty("boolTrue", true); + json.addProperty("boolFalse", false); + json.addProperty("int32", 42); + json.addProperty("int64", 9223372036854775807L); + json.addProperty("float", 3.14f); + json.addProperty("double", 3.115281878499445d); + json.addProperty("string", "Hello, World!"); + // array + final JsonArray array = new JsonArray(); + array.add(1); + array.add("two"); + array.add(true); + json.add("array", array); + // nested map + final JsonObject nested = new JsonObject(); + nested.addProperty("nested_key", "nested_value"); + json.add("map", nested); + + // Encode and decode + final byte[] encoded = GarminJson.encode(json); + final JsonObject decoded = (JsonObject) GarminJson.decode(encoded); + + // Validate + Assert.assertEquals(json.size(), decoded.size()); + Assert.assertTrue(decoded.get("nullValue").isJsonNull()); + Assert.assertEquals(json.get("boolTrue").getAsBoolean(), decoded.get("boolTrue").getAsBoolean()); + Assert.assertEquals(json.get("boolFalse").getAsBoolean(), decoded.get("boolFalse").getAsBoolean()); + Assert.assertEquals(json.get("int32").getAsInt(), decoded.get("int32").getAsInt()); + Assert.assertEquals(json.get("int64").getAsLong(), decoded.get("int64").getAsLong()); + Assert.assertEquals(json.get("float").getAsDouble(), decoded.get("float").getAsDouble(), 0.001); + Assert.assertEquals(json.get("double").getAsDouble(), decoded.get("double").getAsDouble(), 0.000001); + Assert.assertEquals(json.get("string").getAsString(), decoded.get("string").getAsString()); + Assert.assertEquals(json.getAsJsonArray("array").size(), decoded.getAsJsonArray("array").size()); + for (int i = 0; i < json.getAsJsonArray("array").size(); i++) { + Assert.assertEquals(json.getAsJsonArray("array").get(i), decoded.getAsJsonArray("array").get(i)); + } + Assert.assertEquals( + json.getAsJsonObject("map").get("nested_key").getAsString(), + decoded.getAsJsonObject("map").get("nested_key").getAsString() + ); + } + + @Test + public void testEmptyObject() throws Exception { + final JsonObject json = new JsonObject(); + + final byte[] encoded = GarminJson.encode(json); + + Assert.assertEquals( + "da7ada7a000000050b00000000", + GB.hexdump(encoded).toLowerCase() + ); + + final JsonObject decoded = (JsonObject) GarminJson.decode(encoded); + + Assert.assertEquals(0, decoded.size()); + } + + @Test + public void testSimpleKeyValue() throws Exception { + final JsonObject json = new JsonObject(); + json.addProperty("key", "value"); + + final byte[] encoded = GarminJson.encode(json); + + Assert.assertEquals( + "abcdabcd0000000e00046b657900000676616c756500da7ada7a0000000f0b0000000103000000000300000006", + GB.hexdump(encoded).toLowerCase() + ); + + final JsonObject decoded = (JsonObject) GarminJson.decode(encoded); + + Assert.assertEquals(1, decoded.size()); + Assert.assertEquals("value", decoded.get("key").getAsString()); + } +}