@mentra/bluetooth-sdk 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +135 -51
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +120 -17
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +34 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +13 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +551 -159
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +109 -73
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -9
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
- package/build/BluetoothSdk.types.d.ts +37 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +4 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +1 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +5 -0
- package/build/index.js.map +1 -1
- package/build/types/index.d.ts +3 -0
- package/build/types/index.d.ts.map +1 -0
- package/build/types/index.js +2 -0
- package/build/types/index.js.map +1 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +136 -30
- package/ios/Source/DeviceStore.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +40 -24
- package/ios/Source/OtaManifest.swift +1 -1
- package/ios/Source/camera/CameraModels.swift +17 -3
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/G2.swift +518 -11
- package/ios/Source/sgcs/MentraLive.swift +443 -42
- package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
- package/ios/Source/sgcs/MentraNex.swift +416 -32
- package/ios/Source/sgcs/SGCManager.swift +155 -0
- package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
- package/ios/Source/status/DeviceStatus.swift +0 -8
- package/ios/Source/utils/BleJsonCompact.swift +395 -0
- package/ios/Source/utils/BleWireProtocol.swift +92 -0
- package/ios/Source/utils/G1Text.swift +30 -1
- package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
- package/ios/Source/utils/MessageChunker.swift +73 -0
- package/package.json +14 -7
- package/src/BluetoothSdk.types.ts +39 -1
- package/src/_private/BluetoothSdkModule.ts +4 -0
- package/src/_private/photoRequestPayload.ts +1 -0
- package/src/index.ts +6 -0
- package/src/types/index.ts +7 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.utils;
|
|
2
|
+
|
|
3
|
+
import java.nio.charset.StandardCharsets;
|
|
4
|
+
import java.util.ArrayList;
|
|
5
|
+
import java.util.Arrays;
|
|
6
|
+
import java.util.Collections;
|
|
7
|
+
import java.util.HashMap;
|
|
8
|
+
import java.util.HashSet;
|
|
9
|
+
import java.util.Iterator;
|
|
10
|
+
import java.util.List;
|
|
11
|
+
import java.util.Map;
|
|
12
|
+
import java.util.Set;
|
|
13
|
+
import org.json.JSONArray;
|
|
14
|
+
import org.json.JSONException;
|
|
15
|
+
import org.json.JSONObject;
|
|
16
|
+
|
|
17
|
+
/** BLE Wire Protocol v2 JSON compaction: short keys, enum ints, omitted defaults, config diff. */
|
|
18
|
+
public final class BleJsonCompact {
|
|
19
|
+
|
|
20
|
+
private static final Map<String, String> LONG_TO_SHORT = new HashMap<>();
|
|
21
|
+
private static final Map<String, String> SHORT_TO_LONG = new HashMap<>();
|
|
22
|
+
|
|
23
|
+
private static final Map<String, Integer> PHOTO_STATUS_TO_INT = new HashMap<>();
|
|
24
|
+
private static final Map<Integer, String> PHOTO_STATUS_FROM_INT = new HashMap<>();
|
|
25
|
+
private static final Map<String, Integer> KIND_TO_INT = new HashMap<>();
|
|
26
|
+
private static final Map<Integer, String> KIND_FROM_INT = new HashMap<>();
|
|
27
|
+
private static final Map<String, Integer> SOURCE_TO_INT = new HashMap<>();
|
|
28
|
+
private static final Map<Integer, String> SOURCE_FROM_INT = new HashMap<>();
|
|
29
|
+
private static final Map<String, Integer> AE_STATE_TO_INT = new HashMap<>();
|
|
30
|
+
private static final Map<Integer, String> AE_STATE_FROM_INT = new HashMap<>();
|
|
31
|
+
|
|
32
|
+
public static final String KEY_RESOLVED_CONFIG_HASH = "rch";
|
|
33
|
+
|
|
34
|
+
/** ROI 8–10 message types: compact outbound and accept compact inbound. */
|
|
35
|
+
private static final Set<String> HIGH_ROI_MESSAGE_TYPES =
|
|
36
|
+
Collections.unmodifiableSet(
|
|
37
|
+
new HashSet<>(
|
|
38
|
+
Arrays.asList(
|
|
39
|
+
"photo_status",
|
|
40
|
+
"stream_status",
|
|
41
|
+
"wifi_scan_result",
|
|
42
|
+
"start_stream",
|
|
43
|
+
"photo_response")));
|
|
44
|
+
|
|
45
|
+
/** Structural chunk envelope (always compact on wire). */
|
|
46
|
+
private static final Set<String> CHUNK_MESSAGE_TYPES =
|
|
47
|
+
Collections.unmodifiableSet(new HashSet<>(Arrays.asList("ck", "chunked_msg")));
|
|
48
|
+
|
|
49
|
+
private static long sessionConnectEpochMs;
|
|
50
|
+
private static boolean resolvedConfigSent;
|
|
51
|
+
private static String currentSessionResolvedConfigHash;
|
|
52
|
+
private static final Map<String, JSONObject> resolvedConfigByHash = new HashMap<>();
|
|
53
|
+
|
|
54
|
+
static {
|
|
55
|
+
putKey("type", "t");
|
|
56
|
+
putKey("requestId", "r");
|
|
57
|
+
putKey("timestamp", "ts");
|
|
58
|
+
putKey("status", "s");
|
|
59
|
+
putKey("kind", "k");
|
|
60
|
+
putKey("source", "src");
|
|
61
|
+
putKey("requestedCaptureConfig", "rcc");
|
|
62
|
+
putKey("meteredPreview", "mp");
|
|
63
|
+
putKey("exposureTimeNs", "etn");
|
|
64
|
+
putKey("captureMetadata", "cm");
|
|
65
|
+
putKey("aeStateName", "aes");
|
|
66
|
+
putKey("transferMethod", "tm");
|
|
67
|
+
putKey("manual", "m");
|
|
68
|
+
putKey("reconnecting", "rc");
|
|
69
|
+
|
|
70
|
+
putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "accepted", 0);
|
|
71
|
+
putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "queued", 1);
|
|
72
|
+
putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "configuring", 2);
|
|
73
|
+
putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "capturing", 3);
|
|
74
|
+
putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "captured", 4);
|
|
75
|
+
putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "uploading", 5);
|
|
76
|
+
putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "uploaded", 6);
|
|
77
|
+
putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "failed", 7);
|
|
78
|
+
|
|
79
|
+
putEnum(KIND_TO_INT, KIND_FROM_INT, "lifecycle", 0);
|
|
80
|
+
putEnum(KIND_TO_INT, KIND_FROM_INT, "snapshot", 1);
|
|
81
|
+
|
|
82
|
+
putEnum(SOURCE_TO_INT, SOURCE_FROM_INT, "sdk", 0);
|
|
83
|
+
putEnum(SOURCE_TO_INT, SOURCE_FROM_INT, "button", 1);
|
|
84
|
+
|
|
85
|
+
putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "CONVERGED", 0);
|
|
86
|
+
putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "SEARCHING", 1);
|
|
87
|
+
putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "INACTIVE", 2);
|
|
88
|
+
putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "LOCKED", 3);
|
|
89
|
+
putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "FLASH_REQUIRED", 4);
|
|
90
|
+
putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "PRECAPTURE", 5);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private BleJsonCompact() {}
|
|
94
|
+
|
|
95
|
+
public static void resetSession() {
|
|
96
|
+
sessionConnectEpochMs = 0L;
|
|
97
|
+
resolvedConfigSent = false;
|
|
98
|
+
currentSessionResolvedConfigHash = null;
|
|
99
|
+
resolvedConfigByHash.clear();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public static void markSessionConnected(long epochMs) {
|
|
103
|
+
resetSession();
|
|
104
|
+
sessionConnectEpochMs = epochMs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public static boolean isCameraCommandJson(String jsonData) {
|
|
108
|
+
if (jsonData == null || jsonData.isEmpty()) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
return jsonData.contains("cs_pho")
|
|
112
|
+
|| jsonData.contains("cs_cpho")
|
|
113
|
+
|| jsonData.contains("cs_vid")
|
|
114
|
+
|| jsonData.contains("\"type\":\"take_photo\"");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public static boolean shouldCompactOutbound(String messageType) {
|
|
118
|
+
return messageType != null && HIGH_ROI_MESSAGE_TYPES.contains(messageType);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
public static boolean supportsCompactInbound(String messageType) {
|
|
122
|
+
if (messageType == null || messageType.isEmpty()) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
return HIGH_ROI_MESSAGE_TYPES.contains(messageType)
|
|
126
|
+
|| CHUNK_MESSAGE_TYPES.contains(messageType);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Compact wire form uses short `t` key without verbose `type`. */
|
|
130
|
+
public static boolean isCompactWireForm(JSONObject json) {
|
|
131
|
+
return json != null && json.has("t") && !json.has("type");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public static String extractMessageType(JSONObject json) {
|
|
135
|
+
if (json == null) {
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
if (json.has("type")) {
|
|
139
|
+
return json.optString("type", "");
|
|
140
|
+
}
|
|
141
|
+
if (json.has("t")) {
|
|
142
|
+
return json.optString("t", "");
|
|
143
|
+
}
|
|
144
|
+
return "";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
public static JSONObject encode(JSONObject json) throws JSONException {
|
|
148
|
+
if (json == null) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
if (isCameraCommandJson(json.toString())) {
|
|
152
|
+
return json;
|
|
153
|
+
}
|
|
154
|
+
String messageType = extractMessageType(json);
|
|
155
|
+
if (!shouldCompactOutbound(messageType)) {
|
|
156
|
+
return json;
|
|
157
|
+
}
|
|
158
|
+
return compactObject(json, true);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
public static JSONObject encode(String jsonData) throws JSONException {
|
|
162
|
+
if (jsonData == null || !jsonData.startsWith("{")) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return encode(new JSONObject(jsonData));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
public static JSONObject decode(JSONObject json) throws JSONException {
|
|
169
|
+
if (json == null) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
if (isCameraCommandJson(json.toString())) {
|
|
173
|
+
return json;
|
|
174
|
+
}
|
|
175
|
+
return expandObject(json, true);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Expand compact wire JSON only when the message type is on the ROI allowlist.
|
|
180
|
+
*
|
|
181
|
+
* @return expanded JSON, passthrough expanded JSON, or {@code null} if compact form is unsupported
|
|
182
|
+
*/
|
|
183
|
+
public static JSONObject decodeIfSupported(JSONObject json) throws JSONException {
|
|
184
|
+
if (json == null) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
if (isCameraCommandJson(json.toString())) {
|
|
188
|
+
return json;
|
|
189
|
+
}
|
|
190
|
+
if (!isCompactWireForm(json)) {
|
|
191
|
+
return json;
|
|
192
|
+
}
|
|
193
|
+
String compactType = json.optString("t", "");
|
|
194
|
+
if (!supportsCompactInbound(compactType)) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return expandObject(json, true);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
public static JSONObject decode(String jsonData) throws JSONException {
|
|
201
|
+
if (jsonData == null || !jsonData.startsWith("{")) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
return decode(new JSONObject(jsonData));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private static void putKey(String longKey, String shortKey) {
|
|
208
|
+
LONG_TO_SHORT.put(longKey, shortKey);
|
|
209
|
+
SHORT_TO_LONG.put(shortKey, longKey);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private static void putEnum(
|
|
213
|
+
Map<String, Integer> toInt, Map<Integer, String> fromInt, String value, int code) {
|
|
214
|
+
toInt.put(value, code);
|
|
215
|
+
fromInt.put(code, value);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private static JSONObject compactObject(JSONObject input, boolean topLevel) throws JSONException {
|
|
219
|
+
JSONObject output = new JSONObject();
|
|
220
|
+
String messageType = input.optString("type", input.optString("t", ""));
|
|
221
|
+
boolean skipResolvedConfig = false;
|
|
222
|
+
|
|
223
|
+
if (topLevel && shouldDiffResolvedConfig(messageType) && input.has("resolvedConfig")) {
|
|
224
|
+
JSONObject resolvedConfig = input.getJSONObject("resolvedConfig");
|
|
225
|
+
String hash = hashConfig(resolvedConfig);
|
|
226
|
+
resolvedConfigByHash.put(hash, cloneObject(resolvedConfig));
|
|
227
|
+
if (resolvedConfigSent && hash.equals(currentSessionResolvedConfigHash)) {
|
|
228
|
+
output.put(KEY_RESOLVED_CONFIG_HASH, hash);
|
|
229
|
+
skipResolvedConfig = true;
|
|
230
|
+
} else {
|
|
231
|
+
currentSessionResolvedConfigHash = hash;
|
|
232
|
+
resolvedConfigSent = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
Iterator<String> keys = input.keys();
|
|
237
|
+
while (keys.hasNext()) {
|
|
238
|
+
String key = keys.next();
|
|
239
|
+
if (skipResolvedConfig && "resolvedConfig".equals(key)) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
Object value = input.get(key);
|
|
243
|
+
String shortKey = LONG_TO_SHORT.getOrDefault(key, key);
|
|
244
|
+
Object compacted = compactValue(key, value, messageType);
|
|
245
|
+
if (shouldOmit(compacted)) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
output.put(shortKey, compacted);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (topLevel) {
|
|
252
|
+
compactTimestamp(output);
|
|
253
|
+
}
|
|
254
|
+
return output;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private static boolean shouldDiffResolvedConfig(String messageType) {
|
|
258
|
+
return "photo_status".equals(messageType) || "stream_status".equals(messageType);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private static Object compactValue(String longKey, Object value, String messageType)
|
|
262
|
+
throws JSONException {
|
|
263
|
+
if (value == JSONObject.NULL || value == null) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
if (value instanceof JSONObject) {
|
|
267
|
+
return compactObject((JSONObject) value, false);
|
|
268
|
+
}
|
|
269
|
+
if (value instanceof JSONArray) {
|
|
270
|
+
return compactArray((JSONArray) value);
|
|
271
|
+
}
|
|
272
|
+
if (value instanceof Boolean || value instanceof Number) {
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
String str = String.valueOf(value);
|
|
276
|
+
if ("status".equals(longKey) && "photo_status".equals(messageType)) {
|
|
277
|
+
Integer code = PHOTO_STATUS_TO_INT.get(str);
|
|
278
|
+
if (code != null) {
|
|
279
|
+
return code;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if ("kind".equals(longKey)) {
|
|
283
|
+
Integer code = KIND_TO_INT.get(str);
|
|
284
|
+
if (code != null) {
|
|
285
|
+
return code;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if ("source".equals(longKey)) {
|
|
289
|
+
Integer code = SOURCE_TO_INT.get(str);
|
|
290
|
+
if (code != null) {
|
|
291
|
+
return code;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if ("aeStateName".equals(longKey)) {
|
|
295
|
+
Integer code = AE_STATE_TO_INT.get(str);
|
|
296
|
+
if (code != null) {
|
|
297
|
+
return code;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return str;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private static JSONArray compactArray(JSONArray array) throws JSONException {
|
|
304
|
+
JSONArray out = new JSONArray();
|
|
305
|
+
for (int i = 0; i < array.length(); i++) {
|
|
306
|
+
Object value = array.get(i);
|
|
307
|
+
if (value instanceof JSONObject) {
|
|
308
|
+
out.put(compactObject((JSONObject) value, false));
|
|
309
|
+
} else {
|
|
310
|
+
out.put(value);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private static boolean shouldOmit(Object value) {
|
|
317
|
+
if (value == null || value == JSONObject.NULL) {
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
if (value instanceof Boolean && !((Boolean) value)) {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
if (value instanceof JSONObject && ((JSONObject) value).length() == 0) {
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
if (value instanceof JSONArray && ((JSONArray) value).length() == 0) {
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private static void compactTimestamp(JSONObject output) throws JSONException {
|
|
333
|
+
String tsKey = output.has("timestamp") ? "timestamp" : (output.has("ts") ? "ts" : null);
|
|
334
|
+
if (tsKey == null) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
long absolute = output.optLong(tsKey);
|
|
338
|
+
if (absolute <= 0) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (sessionConnectEpochMs <= 0) {
|
|
342
|
+
sessionConnectEpochMs = absolute;
|
|
343
|
+
}
|
|
344
|
+
output.put("ts", absolute - sessionConnectEpochMs);
|
|
345
|
+
if (!"ts".equals(tsKey)) {
|
|
346
|
+
output.remove(tsKey);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private static JSONObject expandObject(JSONObject input, boolean topLevel) throws JSONException {
|
|
351
|
+
JSONObject output = new JSONObject();
|
|
352
|
+
String messageType = input.optString("type", input.optString("t", ""));
|
|
353
|
+
|
|
354
|
+
Iterator<String> keys = input.keys();
|
|
355
|
+
while (keys.hasNext()) {
|
|
356
|
+
String key = keys.next();
|
|
357
|
+
Object value = input.get(key);
|
|
358
|
+
if (topLevel && "ts".equals(key)) {
|
|
359
|
+
output.put("ts", value);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
String longKey = SHORT_TO_LONG.getOrDefault(key, key);
|
|
363
|
+
Object expanded = expandValue(longKey, value, messageType);
|
|
364
|
+
if (expanded == null || expanded == JSONObject.NULL) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
output.put(longKey, expanded);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (topLevel) {
|
|
371
|
+
expandTimestamp(output);
|
|
372
|
+
expandResolvedConfigHash(output);
|
|
373
|
+
cacheResolvedConfigIfPresent(output);
|
|
374
|
+
}
|
|
375
|
+
return output;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private static void cacheResolvedConfigIfPresent(JSONObject output) throws JSONException {
|
|
379
|
+
if (!output.has("resolvedConfig")) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
JSONObject resolvedConfig = output.getJSONObject("resolvedConfig");
|
|
383
|
+
String hash = hashConfig(resolvedConfig);
|
|
384
|
+
resolvedConfigByHash.put(hash, cloneObject(resolvedConfig));
|
|
385
|
+
currentSessionResolvedConfigHash = hash;
|
|
386
|
+
resolvedConfigSent = true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private static void expandResolvedConfigHash(JSONObject output) throws JSONException {
|
|
390
|
+
if (output.has("resolvedConfig") || !output.has(KEY_RESOLVED_CONFIG_HASH)) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
String hash = output.optString(KEY_RESOLVED_CONFIG_HASH, "");
|
|
394
|
+
JSONObject cached = resolvedConfigByHash.get(hash);
|
|
395
|
+
if (cached != null) {
|
|
396
|
+
output.put("resolvedConfig", cloneObject(cached));
|
|
397
|
+
}
|
|
398
|
+
output.remove(KEY_RESOLVED_CONFIG_HASH);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private static Object expandValue(String longKey, Object value, String messageType)
|
|
402
|
+
throws JSONException {
|
|
403
|
+
if (value == JSONObject.NULL || value == null) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
if (value instanceof JSONObject) {
|
|
407
|
+
return expandObject((JSONObject) value, false);
|
|
408
|
+
}
|
|
409
|
+
if (value instanceof JSONArray) {
|
|
410
|
+
JSONArray out = new JSONArray();
|
|
411
|
+
JSONArray array = (JSONArray) value;
|
|
412
|
+
for (int i = 0; i < array.length(); i++) {
|
|
413
|
+
Object item = array.get(i);
|
|
414
|
+
if (item instanceof JSONObject) {
|
|
415
|
+
out.put(expandObject((JSONObject) item, false));
|
|
416
|
+
} else {
|
|
417
|
+
out.put(item);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
if ("status".equals(longKey) && value instanceof Number && "photo_status".equals(messageType)) {
|
|
423
|
+
String restored = PHOTO_STATUS_FROM_INT.get(((Number) value).intValue());
|
|
424
|
+
return restored != null ? restored : value;
|
|
425
|
+
}
|
|
426
|
+
if ("kind".equals(longKey) && value instanceof Number) {
|
|
427
|
+
String restored = KIND_FROM_INT.get(((Number) value).intValue());
|
|
428
|
+
return restored != null ? restored : value;
|
|
429
|
+
}
|
|
430
|
+
if ("source".equals(longKey) && value instanceof Number) {
|
|
431
|
+
String restored = SOURCE_FROM_INT.get(((Number) value).intValue());
|
|
432
|
+
return restored != null ? restored : value;
|
|
433
|
+
}
|
|
434
|
+
if ("aeStateName".equals(longKey) && value instanceof Number) {
|
|
435
|
+
String restored = AE_STATE_FROM_INT.get(((Number) value).intValue());
|
|
436
|
+
return restored != null ? restored : value;
|
|
437
|
+
}
|
|
438
|
+
return value;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private static void expandTimestamp(JSONObject output) throws JSONException {
|
|
442
|
+
if (!output.has("timestamp") && output.has("ts")) {
|
|
443
|
+
long delta = output.optLong("ts");
|
|
444
|
+
long absolute = sessionConnectEpochMs > 0 ? sessionConnectEpochMs + delta : delta;
|
|
445
|
+
output.put("timestamp", absolute);
|
|
446
|
+
output.remove("ts");
|
|
447
|
+
if (sessionConnectEpochMs <= 0) {
|
|
448
|
+
sessionConnectEpochMs = absolute - delta;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
public static String hashConfig(JSONObject config) {
|
|
454
|
+
try {
|
|
455
|
+
return fnv1a32Hex(canonicalObject(config).toString());
|
|
456
|
+
} catch (JSONException e) {
|
|
457
|
+
return fnv1a32Hex(config.toString());
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private static JSONObject canonicalObject(JSONObject input) throws JSONException {
|
|
462
|
+
List<String> keys = new ArrayList<>();
|
|
463
|
+
Iterator<String> it = input.keys();
|
|
464
|
+
while (it.hasNext()) {
|
|
465
|
+
keys.add(it.next());
|
|
466
|
+
}
|
|
467
|
+
Collections.sort(keys);
|
|
468
|
+
JSONObject out = new JSONObject();
|
|
469
|
+
for (String key : keys) {
|
|
470
|
+
Object value = input.get(key);
|
|
471
|
+
if (value instanceof JSONObject) {
|
|
472
|
+
out.put(key, canonicalObject((JSONObject) value));
|
|
473
|
+
} else {
|
|
474
|
+
out.put(key, value);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private static JSONObject cloneObject(JSONObject input) throws JSONException {
|
|
481
|
+
return new JSONObject(input.toString());
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private static String fnv1a32Hex(String value) {
|
|
485
|
+
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
|
486
|
+
int hash = 0x811c9dc5;
|
|
487
|
+
for (byte b : bytes) {
|
|
488
|
+
hash ^= (b & 0xff);
|
|
489
|
+
hash *= 0x01000193;
|
|
490
|
+
}
|
|
491
|
+
return String.format("%08x", hash);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
@@ -37,7 +37,10 @@ import org.json.JSONObject;
|
|
|
37
37
|
*/
|
|
38
38
|
public class BlePhotoUploadService {
|
|
39
39
|
private static final String TAG = "BlePhotoUploadService";
|
|
40
|
-
|
|
40
|
+
// AVIF sources already went through a lossy pass on the glasses; re-encode at
|
|
41
|
+
// high-but-not-max quality to avoid inflating upload size. JPEG fast-path
|
|
42
|
+
// payloads from text mode are uploaded as-is.
|
|
43
|
+
private static final int AVIF_TO_JPEG_QUALITY = 90;
|
|
41
44
|
|
|
42
45
|
public interface UploadCallback {
|
|
43
46
|
void onSuccess(String requestId, String responseBody);
|
|
@@ -81,6 +84,18 @@ public class BlePhotoUploadService {
|
|
|
81
84
|
*/
|
|
82
85
|
@VisibleForTesting
|
|
83
86
|
static byte[] convertToJpegPreservingExif(byte[] imageData) throws Exception {
|
|
87
|
+
long conversionStartMs = System.currentTimeMillis();
|
|
88
|
+
if (isJpeg(imageData)) {
|
|
89
|
+
Log.d(
|
|
90
|
+
TAG,
|
|
91
|
+
"BLE relay pass-through: input already JPEG ("
|
|
92
|
+
+ imageData.length
|
|
93
|
+
+ " bytes), skipping decode/re-encode in "
|
|
94
|
+
+ (System.currentTimeMillis() - conversionStartMs)
|
|
95
|
+
+ "ms");
|
|
96
|
+
return imageData;
|
|
97
|
+
}
|
|
98
|
+
|
|
84
99
|
File inputFile = File.createTempFile("ble_photo_in_", guessExtension(imageData));
|
|
85
100
|
File outputFile = File.createTempFile("ble_photo_out_", ".jpg");
|
|
86
101
|
try {
|
|
@@ -92,19 +107,39 @@ public class BlePhotoUploadService {
|
|
|
92
107
|
|
|
93
108
|
String imuJson = readImuJsonFromBleImage(imageData, inputFile.getAbsolutePath());
|
|
94
109
|
|
|
110
|
+
long decodeStartMs = System.currentTimeMillis();
|
|
95
111
|
Bitmap bitmap = decodeImage(imageData);
|
|
112
|
+
long decodeDurationMs = System.currentTimeMillis() - decodeStartMs;
|
|
96
113
|
if (bitmap == null) {
|
|
97
114
|
throw new Exception("Failed to decode image data");
|
|
98
115
|
}
|
|
99
116
|
|
|
100
|
-
Log.d(
|
|
101
|
-
|
|
117
|
+
Log.d(
|
|
118
|
+
TAG,
|
|
119
|
+
"AVIF decode complete: "
|
|
120
|
+
+ bitmap.getWidth()
|
|
121
|
+
+ "x"
|
|
122
|
+
+ bitmap.getHeight()
|
|
123
|
+
+ " in "
|
|
124
|
+
+ decodeDurationMs
|
|
125
|
+
+ "ms");
|
|
126
|
+
|
|
127
|
+
long encodeStartMs = System.currentTimeMillis();
|
|
102
128
|
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
|
|
103
|
-
bitmap.compress(Bitmap.CompressFormat.JPEG,
|
|
129
|
+
bitmap.compress(Bitmap.CompressFormat.JPEG, AVIF_TO_JPEG_QUALITY, fos);
|
|
104
130
|
} finally {
|
|
105
131
|
bitmap.recycle();
|
|
106
132
|
}
|
|
133
|
+
long encodeDurationMs = System.currentTimeMillis() - encodeStartMs;
|
|
134
|
+
Log.d(
|
|
135
|
+
TAG,
|
|
136
|
+
"AVIF->JPEG encode complete: quality="
|
|
137
|
+
+ AVIF_TO_JPEG_QUALITY
|
|
138
|
+
+ " in "
|
|
139
|
+
+ encodeDurationMs
|
|
140
|
+
+ "ms");
|
|
107
141
|
|
|
142
|
+
long metadataStartMs = System.currentTimeMillis();
|
|
108
143
|
if (imuJson != null && !imuJson.isEmpty()) {
|
|
109
144
|
logImuData(imuJson);
|
|
110
145
|
// IMU EXIF is enrichment, not the payload: a write failure must not drop the
|
|
@@ -128,8 +163,27 @@ public class BlePhotoUploadService {
|
|
|
128
163
|
+ "). If rawHasExifMarker=true, EXIF may be present but unreadable via"
|
|
129
164
|
+ " ExifInterface on this container.");
|
|
130
165
|
}
|
|
166
|
+
Log.d(
|
|
167
|
+
TAG,
|
|
168
|
+
"AVIF metadata handling complete in "
|
|
169
|
+
+ (System.currentTimeMillis() - metadataStartMs)
|
|
170
|
+
+ "ms");
|
|
131
171
|
|
|
132
|
-
|
|
172
|
+
byte[] jpegData = java.nio.file.Files.readAllBytes(outputFile.toPath());
|
|
173
|
+
Log.d(
|
|
174
|
+
TAG,
|
|
175
|
+
"Phone image conversion complete: AVIF "
|
|
176
|
+
+ imageData.length
|
|
177
|
+
+ " bytes -> JPEG "
|
|
178
|
+
+ jpegData.length
|
|
179
|
+
+ " bytes in "
|
|
180
|
+
+ (System.currentTimeMillis() - conversionStartMs)
|
|
181
|
+
+ "ms (decode="
|
|
182
|
+
+ decodeDurationMs
|
|
183
|
+
+ "ms, encode="
|
|
184
|
+
+ encodeDurationMs
|
|
185
|
+
+ "ms)");
|
|
186
|
+
return jpegData;
|
|
133
187
|
} finally {
|
|
134
188
|
if (!inputFile.delete()) {
|
|
135
189
|
inputFile.deleteOnExit();
|
|
@@ -251,6 +305,12 @@ public class BlePhotoUploadService {
|
|
|
251
305
|
exif.saveAttributes();
|
|
252
306
|
}
|
|
253
307
|
|
|
308
|
+
private static boolean isJpeg(byte[] imageData) {
|
|
309
|
+
return imageData.length >= 2
|
|
310
|
+
&& (imageData[0] & 0xFF) == 0xFF
|
|
311
|
+
&& (imageData[1] & 0xFF) == 0xD8;
|
|
312
|
+
}
|
|
313
|
+
|
|
254
314
|
private static String guessExtension(byte[] imageData) {
|
|
255
315
|
if (imageData.length > 12
|
|
256
316
|
&& imageData[4] == 'f'
|