@mentra/bluetooth-sdk 0.1.21-beta.5 → 3.1.0-dev.7

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.
Files changed (114) hide show
  1. package/README.md +38 -2
  2. package/android/build.gradle +7 -4
  3. package/android/lc3Lib/build.gradle +18 -4
  4. package/android/silero/build.gradle +9 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +39 -3
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +198 -17
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +112 -8
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
  13. package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
  20. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1174 -191
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  27. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  33. package/build/BluetoothSdk.types.d.ts +61 -2
  34. package/build/BluetoothSdk.types.d.ts.map +1 -1
  35. package/build/BluetoothSdk.types.js.map +1 -1
  36. package/build/_internal.d.ts +2 -0
  37. package/build/_internal.d.ts.map +1 -1
  38. package/build/_internal.js +2 -0
  39. package/build/_internal.js.map +1 -1
  40. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  41. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  42. package/build/_private/BluetoothSdkModule.js +1 -1
  43. package/build/_private/BluetoothSdkModule.js.map +1 -1
  44. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  45. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  46. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  47. package/build/_private/sdkOtaManifest.d.ts +3 -12
  48. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  49. package/build/_private/sdkOtaManifest.js +5 -17
  50. package/build/_private/sdkOtaManifest.js.map +1 -1
  51. package/build/generated/releaseMetadata.d.ts +12 -0
  52. package/build/generated/releaseMetadata.d.ts.map +1 -0
  53. package/build/generated/releaseMetadata.js +10 -0
  54. package/build/generated/releaseMetadata.js.map +1 -0
  55. package/build/index.d.ts +1 -1
  56. package/build/index.d.ts.map +1 -1
  57. package/build/index.js +10 -2
  58. package/build/index.js.map +1 -1
  59. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  60. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  61. package/build/ota-server/MentraOtaServer.types.js +2 -0
  62. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  63. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  64. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  65. package/build/ota-server/MentraOtaServerModule.js +3 -0
  66. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  67. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  68. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  70. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  71. package/build/ota-server/index.d.ts +3 -0
  72. package/build/ota-server/index.d.ts.map +1 -0
  73. package/build/ota-server/index.js +3 -0
  74. package/build/ota-server/index.js.map +1 -0
  75. package/expo-module.config.json +3 -2
  76. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  77. package/ios/BluetoothSdkModule.swift +16 -2
  78. package/ios/LocalIPv4.swift +150 -0
  79. package/ios/LocalOtaServer.swift +309 -0
  80. package/ios/MentraBluetoothSDK.podspec +5 -1
  81. package/ios/MentraOtaServerModule.swift +147 -0
  82. package/ios/MentraPhotoReceiverModule.swift +1 -81
  83. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  84. package/ios/Source/Bridge.swift +47 -3
  85. package/ios/Source/DeviceManager.swift +135 -10
  86. package/ios/Source/DeviceStore.swift +2 -0
  87. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  88. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  89. package/ios/Source/OtaManifest.swift +5 -7
  90. package/ios/Source/audio/AudioModels.swift +17 -0
  91. package/ios/Source/camera/CameraModels.swift +1 -0
  92. package/ios/Source/events/BluetoothEvents.swift +27 -0
  93. package/ios/Source/sgcs/MentraLive.swift +439 -30
  94. package/ios/Source/sgcs/SGCManager.swift +11 -0
  95. package/ios/Source/status/DeviceStatus.swift +23 -0
  96. package/ios/Source/types/DeviceModels.swift +23 -2
  97. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  98. package/package.json +5 -1
  99. package/plugin/build/withIos.d.ts +3 -1
  100. package/plugin/build/withIos.js +22 -10
  101. package/scripts/verify-release-package.mjs +34 -0
  102. package/scripts/write-release-metadata.mjs +154 -0
  103. package/scripts/write-release-metadata.test.mjs +29 -0
  104. package/src/BluetoothSdk.types.ts +72 -3
  105. package/src/_internal.ts +2 -0
  106. package/src/_private/BluetoothSdkModule.ts +5 -5
  107. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  108. package/src/_private/sdkOtaManifest.ts +5 -17
  109. package/src/generated/releaseMetadata.ts +20 -0
  110. package/src/index.ts +19 -2
  111. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  112. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  113. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  114. package/src/ota-server/index.ts +2 -0
@@ -56,6 +56,9 @@ abstract class SGCManager {
56
56
  abstract fun stopStream()
57
57
  abstract fun sendStreamKeepAlive(message: MutableMap<String, Any>)
58
58
  abstract fun startVideoRecording(requestId: String, save: Boolean, sound: Boolean)
59
+ open fun queryVideoRecordingStatus(requestId: String) {
60
+ Bridge.log("SGC: queryVideoRecordingStatus operation not supported")
61
+ }
59
62
 
60
63
  /**
61
64
  * Start video recording with optional per-recording resolution/fps. A width,
@@ -308,6 +311,11 @@ abstract class SGCManager {
308
311
  Bridge.log("SGC: sendSetSystemTime not supported on $type")
309
312
  }
310
313
 
314
+ /** Enable/disable Wi-Fi ADB on Mentra Live (no-op on other devices). */
315
+ open fun sendWifiAdbState(enabled: Boolean) {
316
+ Bridge.log("SGC: sendWifiAdbState not supported on $type")
317
+ }
318
+
311
319
  // User Context (for crash reporting)
312
320
  abstract fun sendUserEmailToGlasses(email: String)
313
321
 
@@ -43,6 +43,7 @@ internal data class GlassesStatus(
43
43
  val besFirmwareVersion: String,
44
44
  val mtkFirmwareVersion: String,
45
45
  val bluetoothMacAddress: String,
46
+ val wifiMacAddress: String,
46
47
  val leftMacAddress: String,
47
48
  val rightMacAddress: String,
48
49
  val macAddress: String,
@@ -50,6 +51,7 @@ internal data class GlassesStatus(
50
51
  val systemTimeMs: Long?,
51
52
  val otaVersionUrl: String,
52
53
  val appVersion: String,
54
+ val hotspotOtaVersion: Int,
53
55
  val bluetoothName: String,
54
56
  val serialNumber: String,
55
57
  val style: String,
@@ -85,12 +87,14 @@ internal data class GlassesStatus(
85
87
  "besFirmwareVersion" to besFirmwareVersion,
86
88
  "mtkFirmwareVersion" to mtkFirmwareVersion,
87
89
  "bluetoothMacAddress" to bluetoothMacAddress,
90
+ "wifiMacAddress" to wifiMacAddress,
88
91
  "leftMacAddress" to leftMacAddress,
89
92
  "rightMacAddress" to rightMacAddress,
90
93
  "macAddress" to macAddress,
91
94
  "buildNumber" to buildNumber,
92
95
  "otaVersionUrl" to otaVersionUrl,
93
96
  "appVersion" to appVersion,
97
+ "hotspotOtaVersion" to hotspotOtaVersion,
94
98
  "bluetoothName" to bluetoothName,
95
99
  "serialNumber" to serialNumber,
96
100
  "style" to style,
@@ -134,6 +138,7 @@ internal data class GlassesStatus(
134
138
  besFirmwareVersion = stringValue(values, "besFirmwareVersion") ?: "",
135
139
  mtkFirmwareVersion = stringValue(values, "mtkFirmwareVersion") ?: "",
136
140
  bluetoothMacAddress = stringValue(values, "bluetoothMacAddress") ?: "",
141
+ wifiMacAddress = stringValue(values, "wifiMacAddress") ?: "",
137
142
  leftMacAddress = stringValue(values, "leftMacAddress") ?: "",
138
143
  rightMacAddress = stringValue(values, "rightMacAddress") ?: "",
139
144
  macAddress = stringValue(values, "macAddress") ?: "",
@@ -141,6 +146,7 @@ internal data class GlassesStatus(
141
146
  systemTimeMs = longValue(values, "systemTimeMs"),
142
147
  otaVersionUrl = stringValue(values, "otaVersionUrl") ?: "",
143
148
  appVersion = stringValue(values, "appVersion") ?: "",
149
+ hotspotOtaVersion = numberValue(values, "hotspotOtaVersion") ?: 0,
144
150
  bluetoothName = stringValue(values, "bluetoothName") ?: "",
145
151
  serialNumber = stringValue(values, "serialNumber") ?: "",
146
152
  style = stringValue(values, "style") ?: "",
@@ -173,6 +179,7 @@ data class VersionInfoResult(
173
179
  val systemTimeMs: Long?,
174
180
  val otaVersionUrl: String,
175
181
  val appVersion: String,
182
+ val hotspotOtaVersion: Int,
176
183
  ) {
177
184
  internal fun toMap(): Map<String, Any> =
178
185
  buildMap {
@@ -184,6 +191,7 @@ data class VersionInfoResult(
184
191
  systemTimeMs?.let { put("systemTimeMs", it) }
185
192
  put("otaVersionUrl", otaVersionUrl)
186
193
  put("appVersion", appVersion)
194
+ put("hotspotOtaVersion", hotspotOtaVersion)
187
195
  }
188
196
 
189
197
  companion object {
@@ -197,6 +205,7 @@ data class VersionInfoResult(
197
205
  systemTimeMs = status.systemTimeMs,
198
206
  otaVersionUrl = status.otaVersionUrl,
199
207
  appVersion = status.appVersion,
208
+ hotspotOtaVersion = status.hotspotOtaVersion,
200
209
  )
201
210
 
202
211
  internal fun fromMap(values: Map<String, Any>): VersionInfoResult =
@@ -209,6 +218,8 @@ data class VersionInfoResult(
209
218
  systemTimeMs = longValue(values, "systemTimeMs", "system_time_ms"),
210
219
  otaVersionUrl = stringValue(values, "otaVersionUrl", "ota_version_url") ?: "",
211
220
  appVersion = stringValue(values, "appVersion", "app_version") ?: "",
221
+ hotspotOtaVersion =
222
+ numberValue(values, "hotspotOtaVersion", "hotspot_ota_version") ?: 0,
212
223
  )
213
224
  }
214
225
  }
@@ -373,12 +384,14 @@ internal data class GlassesStatusUpdate(
373
384
  val besFirmwareVersion: String? = null,
374
385
  val mtkFirmwareVersion: String? = null,
375
386
  val bluetoothMacAddress: String? = null,
387
+ val wifiMacAddress: String? = null,
376
388
  val leftMacAddress: String? = null,
377
389
  val rightMacAddress: String? = null,
378
390
  val macAddress: String? = null,
379
391
  val buildNumber: String? = null,
380
392
  val otaVersionUrl: String? = null,
381
393
  val appVersion: String? = null,
394
+ val hotspotOtaVersion: Int? = null,
382
395
  val bluetoothName: String? = null,
383
396
  val serialNumber: String? = null,
384
397
  val style: String? = null,
@@ -422,12 +435,14 @@ internal data class GlassesStatusUpdate(
422
435
  putIfNotNull("besFirmwareVersion", besFirmwareVersion)
423
436
  putIfNotNull("mtkFirmwareVersion", mtkFirmwareVersion)
424
437
  putIfNotNull("bluetoothMacAddress", bluetoothMacAddress)
438
+ putIfNotNull("wifiMacAddress", wifiMacAddress)
425
439
  putIfNotNull("leftMacAddress", leftMacAddress)
426
440
  putIfNotNull("rightMacAddress", rightMacAddress)
427
441
  putIfNotNull("macAddress", macAddress)
428
442
  putIfNotNull("buildNumber", buildNumber)
429
443
  putIfNotNull("otaVersionUrl", otaVersionUrl)
430
444
  putIfNotNull("appVersion", appVersion)
445
+ putIfNotNull("hotspotOtaVersion", hotspotOtaVersion)
431
446
  putIfNotNull("bluetoothName", bluetoothName)
432
447
  putIfNotNull("serialNumber", serialNumber)
433
448
  putIfNotNull("style", style)
@@ -470,12 +485,14 @@ internal data class GlassesStatusUpdate(
470
485
  besFirmwareVersion = optionalStringValue(values, "besFirmwareVersion"),
471
486
  mtkFirmwareVersion = optionalStringValue(values, "mtkFirmwareVersion"),
472
487
  bluetoothMacAddress = optionalStringValue(values, "bluetoothMacAddress"),
488
+ wifiMacAddress = optionalStringValue(values, "wifiMacAddress"),
473
489
  leftMacAddress = optionalStringValue(values, "leftMacAddress"),
474
490
  rightMacAddress = optionalStringValue(values, "rightMacAddress"),
475
491
  macAddress = optionalStringValue(values, "macAddress"),
476
492
  buildNumber = optionalStringValue(values, "buildNumber"),
477
493
  otaVersionUrl = optionalStringValue(values, "otaVersionUrl"),
478
494
  appVersion = optionalStringValue(values, "appVersion"),
495
+ hotspotOtaVersion = optionalNumberValue(values, "hotspotOtaVersion"),
479
496
  bluetoothName = optionalStringValue(values, "bluetoothName"),
480
497
  serialNumber = optionalStringValue(values, "serialNumber"),
481
498
  style = optionalStringValue(values, "style"),
@@ -43,6 +43,9 @@ data class Device(
43
43
  val rssi: Int? = null,
44
44
  /** Stable app-facing scan-result key. Do not parse; use typed fields instead. */
45
45
  val id: String = address?.takeIf { it.isNotBlank() } ?: "${model.deviceType}:$name",
46
+ val pairingMode: Boolean? = null,
47
+ val pairingCode: String? = null,
48
+ val securePairingCapable: Boolean? = null,
46
49
  ) {
47
50
  internal fun toMap(): Map<String, Any> =
48
51
  buildMap {
@@ -56,6 +59,9 @@ data class Device(
56
59
  put("projectName", it)
57
60
  }
58
61
  rssi?.let { put("rssi", it) }
62
+ pairingMode?.let { put("pairingMode", it) }
63
+ pairingCode?.takeIf { it.isNotBlank() }?.let { put("pairingCode", it) }
64
+ securePairingCapable?.let { put("securePairingCapable", it) }
59
65
  }
60
66
 
61
67
  companion object {
@@ -66,6 +72,9 @@ data class Device(
66
72
  val projectName = stringValue(values, "projectName")?.takeIf { it.isNotBlank() }
67
73
  val rssi = numberValue(values, "rssi")
68
74
  val id = stringValue(values, "id")?.takeIf { it.isNotBlank() } ?: address ?: "${model}:$name"
75
+ val pairingMode = boolValue(values, "pairingMode")
76
+ val pairingCode = stringValue(values, "pairingCode")?.takeIf { it.isNotBlank() }
77
+ val securePairingCapable = boolValue(values, "securePairingCapable")
69
78
  return Device(
70
79
  model = DeviceModel.fromDeviceType(model),
71
80
  name = name,
@@ -73,6 +82,9 @@ data class Device(
73
82
  projectName = projectName,
74
83
  rssi = rssi,
75
84
  id = id,
85
+ pairingMode = pairingMode,
86
+ pairingCode = pairingCode,
87
+ securePairingCapable = securePairingCapable,
76
88
  )
77
89
  }
78
90
  }
@@ -14,7 +14,7 @@ import org.json.JSONArray;
14
14
  import org.json.JSONException;
15
15
  import org.json.JSONObject;
16
16
 
17
- /** BLE Wire Protocol v2 JSON compaction: short keys, enum ints, omitted defaults, config diff. */
17
+ /** BLE Wire Protocol v2 JSON compaction with lossless fallback for ambiguous payloads. */
18
18
  public final class BleJsonCompact {
19
19
 
20
20
  private static final Map<String, String> LONG_TO_SHORT = new HashMap<>();
@@ -47,8 +47,6 @@ public final class BleJsonCompact {
47
47
  Collections.unmodifiableSet(new HashSet<>(Arrays.asList("ck", "chunked_msg")));
48
48
 
49
49
  private static long sessionConnectEpochMs;
50
- private static boolean resolvedConfigSent;
51
- private static String currentSessionResolvedConfigHash;
52
50
  private static final Map<String, JSONObject> resolvedConfigByHash = new HashMap<>();
53
51
 
54
52
  static {
@@ -102,8 +100,6 @@ public final class BleJsonCompact {
102
100
 
103
101
  public static void resetSession() {
104
102
  sessionConnectEpochMs = 0L;
105
- resolvedConfigSent = false;
106
- currentSessionResolvedConfigHash = null;
107
103
  resolvedConfigByHash.clear();
108
104
  }
109
105
 
@@ -160,7 +156,7 @@ public final class BleJsonCompact {
160
156
  return json;
161
157
  }
162
158
  String messageType = extractMessageType(json);
163
- if (!shouldCompactOutbound(messageType)) {
159
+ if (!shouldCompactOutbound(messageType) || !canCompactLosslessly(json, true)) {
164
160
  return json;
165
161
  }
166
162
  return compactObject(json, true);
@@ -223,53 +219,85 @@ public final class BleJsonCompact {
223
219
  fromInt.put(code, value);
224
220
  }
225
221
 
226
- private static JSONObject compactObject(JSONObject input, boolean topLevel) throws JSONException {
227
- JSONObject output = new JSONObject();
222
+ /**
223
+ * Compact JSON has reserved short keys and enum integers. If an otherwise valid future payload
224
+ * already uses one of those wire representations, sending verbose JSON is the only way an older
225
+ * decoder can distinguish the original value.
226
+ */
227
+ private static boolean canCompactLosslessly(JSONObject input, boolean topLevel)
228
+ throws JSONException {
228
229
  String messageType = input.optString("type", input.optString("t", ""));
229
- boolean skipResolvedConfig = false;
230
-
231
- if (topLevel && shouldDiffResolvedConfig(messageType) && input.has("resolvedConfig")) {
232
- JSONObject resolvedConfig = input.getJSONObject("resolvedConfig");
233
- String hash = hashConfig(resolvedConfig);
234
- resolvedConfigByHash.put(hash, cloneObject(resolvedConfig));
235
- if (resolvedConfigSent && hash.equals(currentSessionResolvedConfigHash)) {
236
- output.put(KEY_RESOLVED_CONFIG_HASH, hash);
237
- skipResolvedConfig = true;
238
- } else {
239
- currentSessionResolvedConfigHash = hash;
240
- resolvedConfigSent = true;
230
+ Iterator<String> keys = input.keys();
231
+ while (keys.hasNext()) {
232
+ String key = keys.next();
233
+ Object value = input.get(key);
234
+ if (SHORT_TO_LONG.containsKey(key)
235
+ || (topLevel && KEY_RESOLVED_CONFIG_HASH.equals(key))
236
+ || isAmbiguousEnumValue(key, value, messageType)) {
237
+ return false;
238
+ }
239
+ if (value instanceof JSONObject
240
+ && !canCompactLosslessly((JSONObject) value, false)) {
241
+ return false;
242
+ }
243
+ if (value instanceof JSONArray
244
+ && !canCompactArrayLosslessly((JSONArray) value)) {
245
+ return false;
246
+ }
247
+ }
248
+ return true;
249
+ }
250
+
251
+ private static boolean canCompactArrayLosslessly(JSONArray array) throws JSONException {
252
+ for (int i = 0; i < array.length(); i++) {
253
+ Object value = array.get(i);
254
+ if (value instanceof JSONObject
255
+ && !canCompactLosslessly((JSONObject) value, false)) {
256
+ return false;
241
257
  }
258
+ if (value instanceof JSONArray
259
+ && !canCompactArrayLosslessly((JSONArray) value)) {
260
+ return false;
261
+ }
262
+ }
263
+ return true;
264
+ }
265
+
266
+ private static boolean isAmbiguousEnumValue(
267
+ String key, Object value, String messageType) {
268
+ if (!(value instanceof Number)) {
269
+ return false;
242
270
  }
271
+ return "kind".equals(key)
272
+ || "source".equals(key)
273
+ || "aeStateName".equals(key)
274
+ || ("status".equals(key) && "photo_status".equals(messageType));
275
+ }
276
+
277
+ private static JSONObject compactObject(JSONObject input, boolean topLevel) throws JSONException {
278
+ JSONObject output = new JSONObject();
279
+ String messageType = input.optString("type", input.optString("t", ""));
243
280
 
244
281
  Iterator<String> keys = input.keys();
245
282
  while (keys.hasNext()) {
246
283
  String key = keys.next();
247
- if (skipResolvedConfig && "resolvedConfig".equals(key)) {
248
- continue;
249
- }
250
284
  Object value = input.get(key);
251
- String shortKey = LONG_TO_SHORT.getOrDefault(key, key);
285
+ // Keep the absolute timestamp under its verbose key. Legacy peers recognize it and
286
+ // therefore do not mistake it for the old session-relative `ts` representation.
287
+ String shortKey =
288
+ topLevel && "timestamp".equals(key)
289
+ ? key
290
+ : LONG_TO_SHORT.getOrDefault(key, key);
252
291
  Object compacted = compactValue(key, value, messageType);
253
- if (shouldOmit(compacted)) {
254
- continue;
255
- }
256
292
  output.put(shortKey, compacted);
257
293
  }
258
-
259
- if (topLevel) {
260
- compactTimestamp(output);
261
- }
262
294
  return output;
263
295
  }
264
296
 
265
- private static boolean shouldDiffResolvedConfig(String messageType) {
266
- return "photo_status".equals(messageType) || "stream_status".equals(messageType);
267
- }
268
-
269
297
  private static Object compactValue(String longKey, Object value, String messageType)
270
298
  throws JSONException {
271
299
  if (value == JSONObject.NULL || value == null) {
272
- return null;
300
+ return JSONObject.NULL;
273
301
  }
274
302
  if (value instanceof JSONObject) {
275
303
  return compactObject((JSONObject) value, false);
@@ -321,40 +349,6 @@ public final class BleJsonCompact {
321
349
  return out;
322
350
  }
323
351
 
324
- private static boolean shouldOmit(Object value) {
325
- if (value == null || value == JSONObject.NULL) {
326
- return true;
327
- }
328
- if (value instanceof Boolean && !((Boolean) value)) {
329
- return true;
330
- }
331
- if (value instanceof JSONObject && ((JSONObject) value).length() == 0) {
332
- return true;
333
- }
334
- if (value instanceof JSONArray && ((JSONArray) value).length() == 0) {
335
- return true;
336
- }
337
- return false;
338
- }
339
-
340
- private static void compactTimestamp(JSONObject output) throws JSONException {
341
- String tsKey = output.has("timestamp") ? "timestamp" : (output.has("ts") ? "ts" : null);
342
- if (tsKey == null) {
343
- return;
344
- }
345
- long absolute = output.optLong(tsKey);
346
- if (absolute <= 0) {
347
- return;
348
- }
349
- if (sessionConnectEpochMs <= 0) {
350
- sessionConnectEpochMs = absolute;
351
- }
352
- output.put("ts", absolute - sessionConnectEpochMs);
353
- if (!"ts".equals(tsKey)) {
354
- output.remove(tsKey);
355
- }
356
- }
357
-
358
352
  private static JSONObject expandObject(JSONObject input, boolean topLevel) throws JSONException {
359
353
  JSONObject output = new JSONObject();
360
354
  String messageType = input.optString("type", input.optString("t", ""));
@@ -369,9 +363,6 @@ public final class BleJsonCompact {
369
363
  }
370
364
  String longKey = SHORT_TO_LONG.getOrDefault(key, key);
371
365
  Object expanded = expandValue(longKey, value, messageType);
372
- if (expanded == null || expanded == JSONObject.NULL) {
373
- continue;
374
- }
375
366
  output.put(longKey, expanded);
376
367
  }
377
368
 
@@ -384,14 +375,12 @@ public final class BleJsonCompact {
384
375
  }
385
376
 
386
377
  private static void cacheResolvedConfigIfPresent(JSONObject output) throws JSONException {
387
- if (!output.has("resolvedConfig")) {
378
+ if (!(output.opt("resolvedConfig") instanceof JSONObject)) {
388
379
  return;
389
380
  }
390
381
  JSONObject resolvedConfig = output.getJSONObject("resolvedConfig");
391
382
  String hash = hashConfig(resolvedConfig);
392
383
  resolvedConfigByHash.put(hash, cloneObject(resolvedConfig));
393
- currentSessionResolvedConfigHash = hash;
394
- resolvedConfigSent = true;
395
384
  }
396
385
 
397
386
  private static void expandResolvedConfigHash(JSONObject output) throws JSONException {
@@ -402,14 +391,14 @@ public final class BleJsonCompact {
402
391
  JSONObject cached = resolvedConfigByHash.get(hash);
403
392
  if (cached != null) {
404
393
  output.put("resolvedConfig", cloneObject(cached));
394
+ output.remove(KEY_RESOLVED_CONFIG_HASH);
405
395
  }
406
- output.remove(KEY_RESOLVED_CONFIG_HASH);
407
396
  }
408
397
 
409
398
  private static Object expandValue(String longKey, Object value, String messageType)
410
399
  throws JSONException {
411
400
  if (value == JSONObject.NULL || value == null) {
412
- return null;
401
+ return JSONObject.NULL;
413
402
  }
414
403
  if (value instanceof JSONObject) {
415
404
  return expandObject((JSONObject) value, false);
@@ -448,7 +437,13 @@ public final class BleJsonCompact {
448
437
 
449
438
  private static void expandTimestamp(JSONObject output) throws JSONException {
450
439
  if (!output.has("timestamp") && output.has("ts")) {
451
- long delta = output.optLong("ts");
440
+ Object timestamp = output.opt("ts");
441
+ if (!(timestamp instanceof Number)) {
442
+ output.put("timestamp", timestamp);
443
+ output.remove("ts");
444
+ return;
445
+ }
446
+ long delta = ((Number) timestamp).longValue();
452
447
  long absolute = sessionConnectEpochMs > 0 ? sessionConnectEpochMs + delta : delta;
453
448
  output.put("timestamp", absolute);
454
449
  output.remove("ts");
@@ -429,7 +429,7 @@ public class K900ProtocolUtils {
429
429
  String payloadStr;
430
430
  try {
431
431
  payloadStr = new String(payload, StandardCharsets.UTF_8);
432
- android.util.Log.d("K900ProtocolUtils", "Extracted payload: " + payloadStr);
432
+ android.util.Log.d("K900ProtocolUtils", "Extracted JSON payload bytes: " + payloadLength);
433
433
  } catch (Exception e) {
434
434
  android.util.Log.e("K900ProtocolUtils", "Error converting payload to string", e);
435
435
  return null;
@@ -437,7 +437,7 @@ public class K900ProtocolUtils {
437
437
 
438
438
  // Check if it's valid JSON
439
439
  if (!payloadStr.startsWith("{") || !payloadStr.endsWith("}")) {
440
- android.util.Log.d("K900ProtocolUtils", "Payload is not valid JSON: " + payloadStr);
440
+ android.util.Log.d("K900ProtocolUtils", "Payload is not valid JSON");
441
441
  return null;
442
442
  }
443
443
 
@@ -448,7 +448,7 @@ public class K900ProtocolUtils {
448
448
  // Check if this is C-wrapped format {"C": "..."}
449
449
  if (json.has(FIELD_C)) {
450
450
  String innerContent = json.optString(FIELD_C, "");
451
- android.util.Log.d("K900ProtocolUtils", "Detected C-wrapped format, inner content: " + innerContent);
451
+ android.util.Log.d("K900ProtocolUtils", "Detected C-wrapped format");
452
452
 
453
453
  // Try to parse the inner content as JSON
454
454
  try {
@@ -0,0 +1,30 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class OtaStartResponsePolicyTest {
7
+ @Test
8
+ fun batteryLowFailureRejectsPendingStart() {
9
+ assertThat(otaStartRejectionErrorCode(status("failed", "battery_low")))
10
+ .isEqualTo("battery_low")
11
+ }
12
+
13
+ @Test
14
+ fun unrelatedFailureDoesNotRejectPendingStart() {
15
+ assertThat(otaStartRejectionErrorCode(status("failed", "download_failed"))).isNull()
16
+ }
17
+
18
+ @Test
19
+ fun nonFailureDoesNotRejectPendingStart() {
20
+ assertThat(otaStartRejectionErrorCode(status("in_progress", "battery_low"))).isNull()
21
+ }
22
+
23
+ private fun status(status: String, errorMessage: String): OtaStatusEvent =
24
+ OtaStatusEvent.fromMap(
25
+ mapOf(
26
+ "status" to status,
27
+ "error_message" to errorMessage,
28
+ )
29
+ )
30
+ }
@@ -0,0 +1,39 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.junit.Assert.assertFalse
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Test
6
+
7
+ class ScopedNetworkReadinessTest {
8
+ @Test
9
+ fun `waits for the available network to become foreground`() {
10
+ val readiness = ScopedNetworkReadiness<String>()
11
+
12
+ readiness.onAvailable("glasses")
13
+
14
+ assertFalse(readiness.isReady("glasses", isForeground = false))
15
+ assertTrue(readiness.isReady("glasses", isForeground = true))
16
+ }
17
+
18
+ @Test
19
+ fun `rejects capabilities from a stale network`() {
20
+ val readiness = ScopedNetworkReadiness<String>()
21
+
22
+ readiness.onAvailable("old")
23
+ readiness.onAvailable("current")
24
+
25
+ assertFalse(readiness.isReady("old", isForeground = true))
26
+ assertTrue(readiness.isReady("current", isForeground = true))
27
+ }
28
+
29
+ @Test
30
+ fun `clears the candidate when the request ends`() {
31
+ val readiness = ScopedNetworkReadiness<String>()
32
+ readiness.onAvailable("glasses")
33
+
34
+ readiness.clear()
35
+
36
+ assertFalse(readiness.contains("glasses"))
37
+ assertFalse(readiness.isReady("glasses", isForeground = true))
38
+ }
39
+ }
@@ -0,0 +1,82 @@
1
+ package com.mentra.bluetoothsdk.otaserver
2
+
3
+ import java.io.File
4
+ import java.net.HttpURLConnection
5
+ import java.net.URL
6
+ import org.assertj.core.api.Assertions.assertThat
7
+ import org.junit.After
8
+ import org.junit.Before
9
+ import org.junit.Test
10
+
11
+ class LocalOtaServerTest {
12
+ private lateinit var server: LocalOtaServer
13
+ private lateinit var artifactFile: File
14
+ private var port = 0
15
+
16
+ private val manifest = """{"apps":{"com.mentra.asg_client":{"versionCode":1}}}"""
17
+ private val artifactBytes = ByteArray(1024) { (it % 251).toByte() }
18
+ private val sha = "a".repeat(64)
19
+
20
+ @Before
21
+ fun startServer() {
22
+ artifactFile = File.createTempFile("ota-artifact", ".bin").apply {
23
+ writeBytes(artifactBytes)
24
+ }
25
+ server = LocalOtaServer(onLog = {})
26
+ server.configure(manifest, mapOf(sha to artifactFile))
27
+ port = server.start("127.0.0.1", 0)
28
+ }
29
+
30
+ @After
31
+ fun stopServer() {
32
+ server.close()
33
+ artifactFile.delete()
34
+ }
35
+
36
+ @Test
37
+ fun `serves the configured manifest`() {
38
+ val connection = open("/version.json")
39
+ assertThat(connection.responseCode).isEqualTo(200)
40
+ assertThat(connection.contentType).isEqualTo("application/json")
41
+ assertThat(connection.inputStream.readBytes().decodeToString()).isEqualTo(manifest)
42
+ }
43
+
44
+ @Test
45
+ fun `streams a full artifact`() {
46
+ val connection = open("/artifacts/$sha")
47
+ assertThat(connection.responseCode).isEqualTo(200)
48
+ assertThat(connection.inputStream.readBytes()).isEqualTo(artifactBytes)
49
+ }
50
+
51
+ @Test
52
+ fun `returns 404 for unknown artifacts`() {
53
+ val connection = open("/artifacts/${"b".repeat(64)}")
54
+ assertThat(connection.responseCode).isEqualTo(404)
55
+ }
56
+
57
+ @Test
58
+ fun `rejects non-GET methods`() {
59
+ val connection = open("/version.json")
60
+ connection.requestMethod = "POST"
61
+ connection.doOutput = true
62
+ connection.outputStream.use { it.write("{}".toByteArray()) }
63
+ assertThat(connection.responseCode).isEqualTo(405)
64
+ }
65
+
66
+ @Test
67
+ fun `reconfigures while running`() {
68
+ server.configure("""{"updated":true}""", emptyMap())
69
+ val manifestConnection = open("/version.json")
70
+ assertThat(manifestConnection.inputStream.readBytes().decodeToString())
71
+ .isEqualTo("""{"updated":true}""")
72
+ val artifactConnection = open("/artifacts/$sha")
73
+ assertThat(artifactConnection.responseCode).isEqualTo(404)
74
+ }
75
+
76
+ private fun open(path: String): HttpURLConnection {
77
+ val connection = URL("http://127.0.0.1:$port$path").openConnection() as HttpURLConnection
78
+ connection.connectTimeout = 5_000
79
+ connection.readTimeout = 5_000
80
+ return connection
81
+ }
82
+ }