@mentra/bluetooth-sdk 0.1.7 → 0.1.9

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 (83) hide show
  1. package/README.md +11 -9
  2. package/android/.project +28 -0
  3. package/android/build.gradle +3 -0
  4. package/android/lc3Lib/.project +28 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +15 -3
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +26 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -2
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +136 -4
  9. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +40 -3
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +1 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +1 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +149 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +297 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +120 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +13 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +1 -1
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +1 -1
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +104 -21
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1 -1
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +1 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +1 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +128 -3
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +105 -0
  26. package/build/BluetoothSdk.types.d.ts +66 -29
  27. package/build/BluetoothSdk.types.d.ts.map +1 -1
  28. package/build/BluetoothSdk.types.js +3 -0
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_private/BluetoothSdkModule.d.ts +2 -1
  31. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  32. package/build/_private/BluetoothSdkModule.js +24 -7
  33. package/build/_private/BluetoothSdkModule.js.map +1 -1
  34. package/build/index.d.ts +2 -2
  35. package/build/index.d.ts.map +1 -1
  36. package/build/index.js +2 -3
  37. package/build/index.js.map +1 -1
  38. package/build/photo-receiver/MentraPhotoReceiver.types.d.ts +18 -0
  39. package/build/photo-receiver/MentraPhotoReceiver.types.d.ts.map +1 -0
  40. package/build/photo-receiver/MentraPhotoReceiver.types.js +2 -0
  41. package/build/photo-receiver/MentraPhotoReceiver.types.js.map +1 -0
  42. package/build/photo-receiver/MentraPhotoReceiverModule.d.ts +10 -0
  43. package/build/photo-receiver/MentraPhotoReceiverModule.d.ts.map +1 -0
  44. package/build/photo-receiver/MentraPhotoReceiverModule.js +3 -0
  45. package/build/photo-receiver/MentraPhotoReceiverModule.js.map +1 -0
  46. package/build/photo-receiver/MentraPhotoReceiverModule.web.d.ts +10 -0
  47. package/build/photo-receiver/MentraPhotoReceiverModule.web.d.ts.map +1 -0
  48. package/build/photo-receiver/MentraPhotoReceiverModule.web.js +12 -0
  49. package/build/photo-receiver/MentraPhotoReceiverModule.web.js.map +1 -0
  50. package/build/photo-receiver/index.d.ts +3 -0
  51. package/build/photo-receiver/index.d.ts.map +1 -0
  52. package/build/photo-receiver/index.js +3 -0
  53. package/build/photo-receiver/index.js.map +1 -0
  54. package/expo-module.config.json +5 -2
  55. package/ios/BluetoothSdkModule.swift +19 -3
  56. package/ios/LocalPhotoUploadServer.swift +411 -0
  57. package/ios/MentraBluetoothSDK.podspec +8 -2
  58. package/ios/MentraPhotoReceiverModule.swift +147 -0
  59. package/ios/Source/Bridge.swift +20 -1
  60. package/ios/Source/Camera/CameraModels.swift +64 -11
  61. package/ios/Source/DeviceManager.swift +6 -4
  62. package/ios/Source/Events/BluetoothEvents.swift +3 -0
  63. package/ios/Source/MentraBluetoothSDK.swift +111 -5
  64. package/ios/Source/ObservableStore.swift +2 -2
  65. package/ios/Source/Streaming/StreamModels.swift +234 -40
  66. package/ios/Source/controllers/ControllerManager.swift +1 -1
  67. package/ios/Source/controllers/R1.swift +9 -3
  68. package/ios/Source/sgcs/G1.swift +5 -4
  69. package/ios/Source/sgcs/G2.swift +4 -4
  70. package/ios/Source/sgcs/Mach1.swift +1 -1
  71. package/ios/Source/sgcs/MentraLive.swift +76 -12
  72. package/ios/Source/sgcs/MentraNex.swift +1 -1
  73. package/ios/Source/sgcs/SGCManager.swift +1 -1
  74. package/ios/Source/sgcs/Simulated.swift +1 -1
  75. package/ios/Source/utils/MessageChunkReassembler.swift +89 -0
  76. package/package.json +6 -1
  77. package/src/BluetoothSdk.types.ts +90 -35
  78. package/src/_private/BluetoothSdkModule.ts +38 -8
  79. package/src/index.ts +9 -4
  80. package/src/photo-receiver/MentraPhotoReceiver.types.ts +20 -0
  81. package/src/photo-receiver/MentraPhotoReceiverModule.ts +14 -0
  82. package/src/photo-receiver/MentraPhotoReceiverModule.web.ts +20 -0
  83. package/src/photo-receiver/index.ts +2 -0
@@ -35,12 +35,14 @@ import androidx.core.app.ActivityCompat;
35
35
  import com.mentra.bluetoothsdk.sgcs.SGCManager;
36
36
  import com.mentra.bluetoothsdk.DeviceManager;
37
37
  import com.mentra.bluetoothsdk.Bridge;
38
+ import com.mentra.bluetoothsdk.debug.BleTraceLogger;
38
39
  import com.mentra.bluetoothsdk.utils.DeviceTypes;
39
40
  import com.mentra.bluetoothsdk.utils.ConnTypes;
40
41
  import com.mentra.bluetoothsdk.utils.BitmapJavaUtils;
41
42
  import com.mentra.bluetoothsdk.utils.SmartGlassesConnectionState;
42
43
  import com.mentra.bluetoothsdk.utils.K900ProtocolUtils;
43
44
  import com.mentra.bluetoothsdk.utils.MessageChunker;
45
+ import com.mentra.bluetoothsdk.utils.MessageChunkReassembler;
44
46
  import com.mentra.bluetoothsdk.utils.audio.Lc3Player;
45
47
  import com.mentra.bluetoothsdk.utils.BlePhotoUploadService;
46
48
  import com.mentra.bluetoothsdk.utils.IncidentLogBleRelayNaming;
@@ -248,6 +250,7 @@ public class MentraLive extends SGCManager {
248
250
  private int filePacketBufferSize = 0;
249
251
  private final Object filePacketBufferLock = new Object();
250
252
  private int fileReadNotificationCount = 0; // Debug counter for FILE_READ notifications
253
+ private final MessageChunkReassembler incomingChunkReassembler = new MessageChunkReassembler();
251
254
 
252
255
  private final Object connectionLock = new Object();
253
256
 
@@ -1781,6 +1784,7 @@ public class MentraLive extends SGCManager {
1781
1784
  if ("take_photo".equals(json.optString("type", ""))) {
1782
1785
  Bridge.log("LIVE: PHOTO PIPELINE [4/4] sendJson(build<5) -> sendDataToGlasses — " + summarizeOutgoingMessage(jsonStr));
1783
1786
  }
1787
+ BleTraceLogger.logJson("phone_to_glasses", "sdk_ble_command", json, jsonStr.length());
1784
1788
  sendDataToGlasses(jsonStr, wakeup);
1785
1789
  } else {
1786
1790
  // Add esoteric message ID to the JSON
@@ -1819,6 +1823,7 @@ public class MentraLive extends SGCManager {
1819
1823
  if ("take_photo".equals(json.optString("type", ""))) {
1820
1824
  Bridge.log("LIVE: PHOTO PIPELINE [4/4] sendJson -> sendDataToGlasses (mId=" + messageId + ", ackTimeoutMs=" + ackTimeout + ") — " + summarizeOutgoingMessage(jsonStr));
1821
1825
  }
1826
+ BleTraceLogger.logJson("phone_to_glasses", "sdk_ble_command", json, jsonStr.length());
1822
1827
  sendDataToGlasses(jsonStr, wakeup);
1823
1828
  }
1824
1829
  } catch (JSONException e) {
@@ -2258,6 +2263,13 @@ public class MentraLive extends SGCManager {
2258
2263
  Log.d(TAG, "LIVE: Got some JSON from glasses: " + json.toString());
2259
2264
  }
2260
2265
 
2266
+ if (MessageChunker.isChunkedMessage(json)) {
2267
+ processChunkedJsonMessage(json);
2268
+ return;
2269
+ }
2270
+
2271
+ BleTraceLogger.logJson("glasses_to_phone", "sdk_ble_event", json, json.toString().length());
2272
+
2261
2273
  // Check if this is an ACK response
2262
2274
  String type = json.optString("type", "");
2263
2275
  if ("msg_ack".equals(type)) {
@@ -2287,6 +2299,13 @@ public class MentraLive extends SGCManager {
2287
2299
  case "ble_photo_ready":
2288
2300
  processBlePhotoReady(json);
2289
2301
  break;
2302
+ case "photo_status":
2303
+ try {
2304
+ Bridge.sendPhotoStatus(jsonObjectToMap(json));
2305
+ } catch (JSONException e) {
2306
+ Log.e(TAG, "Error converting photo status to Map", e);
2307
+ }
2308
+ break;
2290
2309
  case "stream_status":
2291
2310
  // Process streaming status update from ASG client
2292
2311
  Bridge.log("LIVE: Received stream status update from glasses: " + json.toString());
@@ -2321,13 +2340,7 @@ public class MentraLive extends SGCManager {
2321
2340
 
2322
2341
  // Forward to websocket system via Bridge (matches iOS emitRtmpStreamStatus)
2323
2342
  try {
2324
- Map<String, Object> rtmpMap = new HashMap<>();
2325
- Iterator<String> keys = json.keys();
2326
- while (keys.hasNext()) {
2327
- String key = keys.next();
2328
- rtmpMap.put(key, json.get(key));
2329
- }
2330
- Bridge.sendStreamStatus(rtmpMap);
2343
+ Bridge.sendStreamStatus(jsonObjectToMap(json));
2331
2344
  } catch (JSONException e) {
2332
2345
  Log.e(TAG, "Error converting RTMP status to Map", e);
2333
2346
  }
@@ -2994,6 +3007,62 @@ public class MentraLive extends SGCManager {
2994
3007
  }
2995
3008
  }
2996
3009
 
3010
+ private Map<String, Object> jsonObjectToMap(JSONObject json) throws JSONException {
3011
+ Map<String, Object> map = new HashMap<>();
3012
+ Iterator<String> keys = json.keys();
3013
+ while (keys.hasNext()) {
3014
+ String key = keys.next();
3015
+ Object value = json.get(key);
3016
+ if (value == JSONObject.NULL) {
3017
+ continue;
3018
+ }
3019
+ map.put(key, jsonValueToBridgeValue(value));
3020
+ }
3021
+ return map;
3022
+ }
3023
+
3024
+ private Object jsonValueToBridgeValue(Object value) throws JSONException {
3025
+ if (value instanceof JSONObject) {
3026
+ return jsonObjectToMap((JSONObject) value);
3027
+ }
3028
+ if (value instanceof JSONArray) {
3029
+ JSONArray array = (JSONArray) value;
3030
+ List<Object> list = new ArrayList<>();
3031
+ for (int i = 0; i < array.length(); i++) {
3032
+ Object item = array.get(i);
3033
+ if (item != JSONObject.NULL) {
3034
+ list.add(jsonValueToBridgeValue(item));
3035
+ }
3036
+ }
3037
+ return list;
3038
+ }
3039
+ return value;
3040
+ }
3041
+
3042
+ private void processChunkedJsonMessage(JSONObject json) {
3043
+ try {
3044
+ MessageChunker.ChunkInfo chunkInfo = MessageChunker.getChunkInfo(json);
3045
+ if (chunkInfo == null) {
3046
+ Log.w(TAG, "LIVE: Received malformed chunked message: " + json);
3047
+ return;
3048
+ }
3049
+
3050
+ String reassembled = incomingChunkReassembler.addChunk(
3051
+ chunkInfo.chunkId,
3052
+ chunkInfo.chunkIndex,
3053
+ chunkInfo.totalChunks,
3054
+ chunkInfo.data);
3055
+ if (reassembled == null) {
3056
+ return;
3057
+ }
3058
+
3059
+ JSONObject reassembledJson = new JSONObject(reassembled);
3060
+ processJsonMessage(reassembledJson);
3061
+ } catch (Exception e) {
3062
+ Log.e(TAG, "Error processing chunked JSON message", e);
3063
+ }
3064
+ }
3065
+
2997
3066
  /**
2998
3067
  * Process K900 command format JSON messages (messages with "C" field)
2999
3068
  */
@@ -3048,6 +3117,7 @@ public class MentraLive extends SGCManager {
3048
3117
  BlePhotoTransfer photoTransfer = blePhotoTransfers.remove(bleImgId);
3049
3118
  if (photoTransfer != null) {
3050
3119
  Bridge.log("LIVE: 🧹 Cleaned up timed out BLE photo transfer for: " + bleImgId);
3120
+ Bridge.sendPhotoError(photoTransfer.requestId, "TRANSFER_TIMEOUT", "Transfer timed out for: " + fileName);
3051
3121
  }
3052
3122
 
3053
3123
  // Reset stale session on incident log relay so a retry starts fresh.
@@ -3080,8 +3150,19 @@ public class MentraLive extends SGCManager {
3080
3150
  return;
3081
3151
  }
3082
3152
 
3153
+ String bleImgId = fileName;
3154
+ int dotIndex = bleImgId.lastIndexOf('.');
3155
+ if (dotIndex > 0) {
3156
+ bleImgId = bleImgId.substring(0, dotIndex);
3157
+ }
3158
+ BlePhotoTransfer photoTransfer = blePhotoTransfers.get(bleImgId);
3159
+ String effectiveRequestId = requestId;
3160
+ if (effectiveRequestId.isEmpty() && photoTransfer != null) {
3161
+ effectiveRequestId = photoTransfer.requestId;
3162
+ }
3163
+
3083
3164
  Log.e(TAG, "❌ Transfer failed for: " + fileName + " (reason: " + reason + ")");
3084
- Bridge.sendPhotoError(requestId, "TRANSFER_FAILED", "Transfer failed for: " + fileName + " (reason: " + reason + ")");
3165
+ Bridge.sendPhotoError(effectiveRequestId, "TRANSFER_FAILED", "Transfer failed for: " + fileName + " (reason: " + reason + ")");
3085
3166
 
3086
3167
  // Clean up any active transfer for this file
3087
3168
  FileTransferSession session = activeFileTransfers.remove(fileName);
@@ -3090,12 +3171,7 @@ public class MentraLive extends SGCManager {
3090
3171
  }
3091
3172
 
3092
3173
  // Clean up any BLE photo transfer
3093
- String bleImgId = fileName;
3094
- int dotIndex = bleImgId.lastIndexOf('.');
3095
- if (dotIndex > 0) {
3096
- bleImgId = bleImgId.substring(0, dotIndex);
3097
- }
3098
- BlePhotoTransfer photoTransfer = blePhotoTransfers.remove(bleImgId);
3174
+ photoTransfer = blePhotoTransfers.remove(bleImgId);
3099
3175
  if (photoTransfer != null) {
3100
3176
  Bridge.log("LIVE: 🧹 Cleaned up failed BLE photo transfer for: " + bleImgId + " (requestId: " + photoTransfer.requestId + ")");
3101
3177
  }
@@ -3352,8 +3428,8 @@ public class MentraLive extends SGCManager {
3352
3428
  // Try to parse the "C" field as JSON
3353
3429
  JSONObject innerJson = new JSONObject(command);
3354
3430
 
3355
- // If it has a "type" field, it's a standard message that got C-wrapped
3356
- if (innerJson.has("type")) {
3431
+ // If it has a "type" field or chunk envelope, it's a standard message that got C-wrapped
3432
+ if (innerJson.has("type") || MessageChunker.isChunkedMessage(innerJson)) {
3357
3433
  String messageType = innerJson.optString("type", "");
3358
3434
  Log.d(TAG, "📦 Detected C-wrapped standard JSON message with type: " + messageType);
3359
3435
  Log.d(TAG, "🔓 Unwrapping and processing through standard message handler");
@@ -4160,9 +4236,9 @@ public class MentraLive extends SGCManager {
4160
4236
  }
4161
4237
  }
4162
4238
 
4163
- public void requestPhoto(String requestId, String appId, String size, String webhookUrl, String authToken, String compress, boolean flash, boolean sound, Long exposureTimeNs) {
4239
+ public void requestPhoto(String requestId, String appId, String size, String webhookUrl, String authToken, String compress, boolean flash, boolean sound, Long exposureTimeNs, Integer iso) {
4164
4240
  boolean hasAuthToken = authToken != null && !authToken.isEmpty();
4165
- Bridge.log("LIVE: Requesting photo: " + requestId + " for app: " + appId + " with size: " + size + ", webhookUrl: " + webhookUrl + ", authToken: " + (hasAuthToken ? "***" : "none") + ", compress=" + compress + ", flash=" + flash + ", sound=" + sound + ", exposureTimeNs=" + exposureTimeNs);
4241
+ Bridge.log("LIVE: Requesting photo: " + requestId + " for app: " + appId + " with size: " + size + ", webhookUrl: " + webhookUrl + ", authToken: " + (hasAuthToken ? "***" : "none") + ", compress=" + compress + ", flash=" + flash + ", sound=" + sound + ", exposureTimeNs=" + exposureTimeNs + ", iso=" + iso);
4166
4242
  Bridge.log("LIVE: PHOTO PIPELINE [5/6] requestPhoto() entry — requestId=" + requestId + ", appId=" + appId);
4167
4243
 
4168
4244
  try {
@@ -4190,6 +4266,10 @@ public class MentraLive extends SGCManager {
4190
4266
  Bridge.log("LIVE: Using manual exposure time for photo request " + requestId + ": " + exposureTimeNs + " ns");
4191
4267
  json.put("exposureTimeNs", exposureTimeNs);
4192
4268
  }
4269
+ if (iso != null && iso > 0) {
4270
+ Bridge.log("LIVE: Using manual ISO for photo request " + requestId + ": ISO " + iso);
4271
+ json.put("iso", iso);
4272
+ }
4193
4273
 
4194
4274
  // Always generate BLE ID for potential fallback
4195
4275
  String bleImgId = "I" + String.format("%09d", System.currentTimeMillis() % 1000000000);
@@ -5723,7 +5803,7 @@ public class MentraLive extends SGCManager {
5723
5803
 
5724
5804
  private String summarizeOutgoingMessage(String payload) {
5725
5805
  if (payload == null || payload.isEmpty()) {
5726
- return "type=unknown, requestId=none, appId=none, transferMethod=none, bleImgId=none, exposureTimeNs=none, mId=none";
5806
+ return "type=unknown, requestId=none, appId=none, transferMethod=none, bleImgId=none, exposureTimeNs=none, iso=none, mId=none";
5727
5807
  }
5728
5808
  try {
5729
5809
  JSONObject obj = new JSONObject(payload);
@@ -5733,6 +5813,7 @@ public class MentraLive extends SGCManager {
5733
5813
  String transferMethod = obj.optString("transferMethod", "none");
5734
5814
  String bleImgId = obj.optString("bleImgId", "none");
5735
5815
  String exposure = obj.has("exposureTimeNs") ? String.valueOf(obj.optLong("exposureTimeNs")) : "none";
5816
+ String iso = obj.has("iso") ? String.valueOf(obj.optInt("iso")) : "none";
5736
5817
  String mId = obj.has("mId") ? String.valueOf(obj.optLong("mId")) : "none";
5737
5818
  return "type=" + type
5738
5819
  + ", requestId=" + requestId
@@ -5740,6 +5821,7 @@ public class MentraLive extends SGCManager {
5740
5821
  + ", transferMethod=" + transferMethod
5741
5822
  + ", bleImgId=" + bleImgId
5742
5823
  + ", exposureTimeNs=" + exposure
5824
+ + ", iso=" + iso
5743
5825
  + ", mId=" + mId;
5744
5826
  } catch (JSONException ignored) {
5745
5827
  return "type=non_json, payloadLen=" + payload.length();
@@ -6152,9 +6234,10 @@ public class MentraLive extends SGCManager {
6152
6234
  Log.e(TAG, "❌ BLE photo transfer incomplete after final packet. Missing " + missingPackets.size() + " packets: " + missingPackets);
6153
6235
  Log.e(TAG, "❌ Telling glasses to retry entire transfer");
6154
6236
 
6155
- // Tell glasses transfer failed, they will retry
6237
+ // Tell glasses transfer failed, they will retry. Keep the photo transfer
6238
+ // entry so the retry still maps back to the original requestId.
6156
6239
  sendTransferCompleteConfirmation(packetInfo.fileName, false);
6157
- blePhotoTransfers.remove(bleImgId);
6240
+ photoTransfer.session = null;
6158
6241
  }
6159
6242
  }
6160
6243
 
@@ -290,7 +290,7 @@ class MentraNex : SGCManager() {
290
290
  override fun requestVersionInfo() { Bridge.log("Nex: requestVersionInfo operation not supported") }
291
291
 
292
292
  // Camera & Media: Not supported on Nex (No camera)
293
- override fun requestPhoto(requestId: String, appId: String, size: String, webhookUrl: String?, authToken: String?, compress: String?, flash: Boolean, sound: Boolean, exposureTimeNs: Long?) { Bridge.log("Nex: requestPhoto operation not supported") }
293
+ override fun requestPhoto(requestId: String, appId: String, size: String, webhookUrl: String?, authToken: String?, compress: String?, flash: Boolean, sound: Boolean, exposureTimeNs: Long?, iso: Int?) { Bridge.log("Nex: requestPhoto operation not supported") }
294
294
  override fun startStream(message: MutableMap<String, Any>) { Bridge.log("Nex: startStream operation not supported") }
295
295
  override fun stopStream() { Bridge.log("Nex: stopStream operation not supported") }
296
296
  override fun sendStreamKeepAlive(message: MutableMap<String, Any>) { Bridge.log("Nex: sendStreamKeepAlive operation not supported") }
@@ -23,6 +23,7 @@ abstract class SGCManager {
23
23
  flash: Boolean,
24
24
  sound: Boolean,
25
25
  exposureTimeNs: Long?,
26
+ iso: Int?,
26
27
  )
27
28
  abstract fun startStream(message: MutableMap<String, Any>)
28
29
  abstract fun stopStream()
@@ -37,6 +37,7 @@ class Simulated : SGCManager() {
37
37
  flash: Boolean,
38
38
  sound: Boolean,
39
39
  exposureTimeNs: Long?,
40
+ iso: Int?,
40
41
  ) {
41
42
  Bridge.log("requestPhoto flash=$flash, sound=$sound")
42
43
  }
@@ -56,11 +56,115 @@ data class StreamAudioConfig @JvmOverloads constructor(
56
56
  }
57
57
  }
58
58
 
59
+ data class StreamResolvedVideoConfig @JvmOverloads constructor(
60
+ val width: Int,
61
+ val height: Int,
62
+ val captureWidth: Int? = null,
63
+ val captureHeight: Int? = null,
64
+ val bitrate: Int,
65
+ val fps: Int,
66
+ ) {
67
+ fun toMap(): Map<String, Any> =
68
+ buildMap {
69
+ put("width", width)
70
+ put("height", height)
71
+ captureWidth?.let { put("captureWidth", it) }
72
+ captureHeight?.let { put("captureHeight", it) }
73
+ put("bitrate", bitrate)
74
+ put("fps", fps)
75
+ }
76
+
77
+ companion object {
78
+ @JvmStatic
79
+ fun fromMap(values: Map<String, Any>?): StreamResolvedVideoConfig? {
80
+ values ?: return null
81
+ return StreamResolvedVideoConfig(
82
+ width = numberValue(values, "width") ?: return null,
83
+ height = numberValue(values, "height") ?: return null,
84
+ captureWidth = numberValue(values, "captureWidth"),
85
+ captureHeight = numberValue(values, "captureHeight"),
86
+ bitrate = numberValue(values, "bitrate") ?: return null,
87
+ fps = numberValue(values, "fps") ?: return null,
88
+ )
89
+ }
90
+ }
91
+ }
92
+
93
+ data class StreamResolvedAudioConfig @JvmOverloads constructor(
94
+ val bitrate: Int? = null,
95
+ val sampleRate: Int? = null,
96
+ val echoCancellation: Boolean? = null,
97
+ val noiseSuppression: Boolean? = null,
98
+ ) {
99
+ fun toMap(): Map<String, Any> =
100
+ buildMap {
101
+ bitrate?.let { put("bitrate", it) }
102
+ sampleRate?.let { put("sampleRate", it) }
103
+ echoCancellation?.let { put("echoCancellation", it) }
104
+ noiseSuppression?.let { put("noiseSuppression", it) }
105
+ }
106
+
107
+ companion object {
108
+ @JvmStatic
109
+ fun fromMap(values: Map<String, Any>?): StreamResolvedAudioConfig? {
110
+ values ?: return null
111
+ return StreamResolvedAudioConfig(
112
+ bitrate = numberValue(values, "bitrate"),
113
+ sampleRate = numberValue(values, "sampleRate"),
114
+ echoCancellation = boolValue(values, "echoCancellation"),
115
+ noiseSuppression = boolValue(values, "noiseSuppression"),
116
+ )
117
+ }
118
+ }
119
+ }
120
+
121
+ enum class StreamTransport(val value: String) {
122
+ RTMP("rtmp"),
123
+ SRT("srt"),
124
+ WHIP("whip");
125
+
126
+ companion object {
127
+ @JvmStatic
128
+ fun fromValue(value: String?): StreamTransport? =
129
+ when (value?.lowercase()) {
130
+ "rtmp" -> RTMP
131
+ "srt" -> SRT
132
+ "whip" -> WHIP
133
+ else -> null
134
+ }
135
+ }
136
+ }
137
+
138
+ data class StreamResolvedConfig @JvmOverloads constructor(
139
+ val transport: StreamTransport? = null,
140
+ val video: StreamResolvedVideoConfig? = null,
141
+ val audio: StreamResolvedAudioConfig? = null,
142
+ ) {
143
+ fun toMap(): Map<String, Any> =
144
+ buildMap {
145
+ transport?.let { put("transport", it.value) }
146
+ video?.let { put("video", it.toMap()) }
147
+ audio?.let { put("audio", it.toMap()) }
148
+ }
149
+
150
+ companion object {
151
+ @JvmStatic
152
+ fun fromMap(values: Map<String, Any>?): StreamResolvedConfig? {
153
+ values ?: return null
154
+ return StreamResolvedConfig(
155
+ transport = StreamTransport.fromValue(stringValue(values, "transport")),
156
+ video = StreamResolvedVideoConfig.fromMap(stringMapValue(values["video"])),
157
+ audio = StreamResolvedAudioConfig.fromMap(stringMapValue(values["audio"])),
158
+ )
159
+ }
160
+ }
161
+ }
162
+
59
163
  data class StreamRequest @JvmOverloads constructor(
60
164
  val streamUrl: String,
61
165
  val streamId: String = "",
62
166
  val keepAlive: Boolean = true,
63
- val keepAliveIntervalSeconds: Int = 15,
167
+ val keepAliveIntervalSeconds: Int = 5,
64
168
  val sound: Boolean = true,
65
169
  val video: StreamVideoConfig? = null,
66
170
  val audio: StreamAudioConfig? = null,
@@ -68,6 +172,7 @@ data class StreamRequest @JvmOverloads constructor(
68
172
  ) {
69
173
  fun toMap(): Map<String, Any> {
70
174
  val values = extraValues.toMutableMap()
175
+ values.remove("keepAliveMode")
71
176
  values["type"] = "start_stream"
72
177
  values["streamUrl"] = streamUrl
73
178
  values["streamId"] = streamId
@@ -90,7 +195,7 @@ data class StreamRequest @JvmOverloads constructor(
90
195
  ?: "",
91
196
  streamId = values["streamId"] as? String ?: "",
92
197
  keepAlive = values["keepAlive"] as? Boolean ?: true,
93
- keepAliveIntervalSeconds = (values["keepAliveIntervalSeconds"] as? Number)?.toInt() ?: 15,
198
+ keepAliveIntervalSeconds = (values["keepAliveIntervalSeconds"] as? Number)?.toInt() ?: 5,
94
199
  sound = values["sound"] as? Boolean ?: true,
95
200
  video = StreamVideoConfig.fromMap(stringMapValue(values["video"])),
96
201
  audio = StreamAudioConfig.fromMap(stringMapValue(values["audio"])),
@@ -99,7 +204,10 @@ data class StreamRequest @JvmOverloads constructor(
99
204
  }
100
205
  }
101
206
 
102
- data class StreamKeepAliveRequest @JvmOverloads constructor(
207
+ internal fun StreamRequest.isExternallyManagedKeepAlive(): Boolean =
208
+ stringValue(extraValues, "keepAliveMode") == "external"
209
+
210
+ internal data class StreamKeepAliveRequest @JvmOverloads constructor(
103
211
  val streamId: String,
104
212
  val ackId: String,
105
213
  val extraValues: Map<String, Any> = emptyMap(),
@@ -162,6 +270,7 @@ sealed interface StreamStatus {
162
270
  val state: StreamState
163
271
  val streamId: String?
164
272
  val timestamp: Long?
273
+ val resolvedConfig: StreamResolvedConfig?
165
274
 
166
275
  fun toMap(): Map<String, Any> {
167
276
  val values = mutableMapOf<String, Any>(
@@ -170,6 +279,7 @@ sealed interface StreamStatus {
170
279
  )
171
280
  streamId?.takeIf { it.isNotBlank() }?.let { values["streamId"] = it }
172
281
  timestamp?.let { values["timestamp"] = it }
282
+ resolvedConfig?.let { values["resolvedConfig"] = it.toMap() }
173
283
 
174
284
  when (this) {
175
285
  is Lifecycle -> Unit
@@ -197,6 +307,7 @@ sealed interface StreamStatus {
197
307
  override val state: StreamState,
198
308
  override val streamId: String?,
199
309
  override val timestamp: Long?,
310
+ override val resolvedConfig: StreamResolvedConfig?,
200
311
  ) : StreamStatus {
201
312
  override val kind: StreamStatusKind = StreamStatusKind.LIFECYCLE
202
313
  }
@@ -207,6 +318,7 @@ sealed interface StreamStatus {
207
318
  val maxAttempts: Int,
208
319
  val reason: String,
209
320
  override val timestamp: Long?,
321
+ override val resolvedConfig: StreamResolvedConfig?,
210
322
  ) : StreamStatus {
211
323
  override val kind: StreamStatusKind = StreamStatusKind.RECONNECT
212
324
  override val state: StreamState = StreamState.RECONNECTING
@@ -216,6 +328,7 @@ sealed interface StreamStatus {
216
328
  override val streamId: String?,
217
329
  val attempt: Int,
218
330
  override val timestamp: Long?,
331
+ override val resolvedConfig: StreamResolvedConfig?,
219
332
  ) : StreamStatus {
220
333
  override val kind: StreamStatusKind = StreamStatusKind.RECONNECT
221
334
  override val state: StreamState = StreamState.RECONNECTED
@@ -225,6 +338,7 @@ sealed interface StreamStatus {
225
338
  override val streamId: String?,
226
339
  val maxAttempts: Int,
227
340
  override val timestamp: Long?,
341
+ override val resolvedConfig: StreamResolvedConfig?,
228
342
  ) : StreamStatus {
229
343
  override val kind: StreamStatusKind = StreamStatusKind.RECONNECT
230
344
  override val state: StreamState = StreamState.RECONNECT_FAILED
@@ -234,6 +348,7 @@ sealed interface StreamStatus {
234
348
  override val streamId: String?,
235
349
  val errorDetails: String,
236
350
  override val timestamp: Long?,
351
+ override val resolvedConfig: StreamResolvedConfig?,
237
352
  ) : StreamStatus {
238
353
  override val kind: StreamStatusKind = StreamStatusKind.ERROR
239
354
  override val state: StreamState = StreamState.ERROR
@@ -246,6 +361,7 @@ sealed interface StreamStatus {
246
361
  override val streamId: String?,
247
362
  val attempt: Int?,
248
363
  override val timestamp: Long?,
364
+ override val resolvedConfig: StreamResolvedConfig?,
249
365
  ) : StreamStatus {
250
366
  override val kind: StreamStatusKind = StreamStatusKind.SNAPSHOT
251
367
  }
@@ -258,6 +374,7 @@ sealed interface StreamStatus {
258
374
  val reconnecting = boolValue(values, "reconnecting") ?: false
259
375
  val streamId = stringValue(values, "streamId")
260
376
  val timestamp = longValue(values, "timestamp")
377
+ val resolvedConfig = StreamResolvedConfig.fromMap(stringMapValue(values["resolvedConfig"]))
261
378
  val attempt = numberValue(values, "attempt")
262
379
  val maxAttempts = numberValue(values, "maxAttempts") ?: 0
263
380
 
@@ -273,6 +390,7 @@ sealed interface StreamStatus {
273
390
  streamId = streamId,
274
391
  attempt = attempt,
275
392
  timestamp = timestamp,
393
+ resolvedConfig = resolvedConfig,
276
394
  )
277
395
  }
278
396
 
@@ -281,6 +399,7 @@ sealed interface StreamStatus {
281
399
  streamId = streamId,
282
400
  errorDetails = rawState?.let { "Unknown stream status: $it" } ?: "Missing stream status",
283
401
  timestamp = timestamp,
402
+ resolvedConfig = resolvedConfig,
284
403
  )
285
404
 
286
405
  return when (state) {
@@ -290,27 +409,32 @@ sealed interface StreamStatus {
290
409
  maxAttempts = maxAttempts,
291
410
  reason = stringValue(values, "reason") ?: "",
292
411
  timestamp = timestamp,
412
+ resolvedConfig = resolvedConfig,
293
413
  )
294
414
  StreamState.RECONNECTED -> Reconnected(
295
415
  streamId = streamId,
296
416
  attempt = attempt ?: 0,
297
417
  timestamp = timestamp,
418
+ resolvedConfig = resolvedConfig,
298
419
  )
299
420
  StreamState.RECONNECT_FAILED -> ReconnectFailed(
300
421
  streamId = streamId,
301
422
  maxAttempts = maxAttempts,
302
423
  timestamp = timestamp,
424
+ resolvedConfig = resolvedConfig,
303
425
  )
304
426
  StreamState.ERROR -> Error(
305
427
  streamId = streamId,
306
428
  errorDetails = stringValue(values, "errorDetails")
307
429
  ?: if (rawState == "error_not_streaming") "not_streaming" else "Unknown stream error",
308
430
  timestamp = timestamp,
431
+ resolvedConfig = resolvedConfig,
309
432
  )
310
433
  else -> Lifecycle(
311
434
  state = state,
312
435
  streamId = streamId,
313
436
  timestamp = timestamp,
437
+ resolvedConfig = resolvedConfig,
314
438
  )
315
439
  }
316
440
  }
@@ -324,6 +448,7 @@ data class StreamStatusEvent(
324
448
 
325
449
  val state: StreamState get() = status.state
326
450
  val streamId: String? get() = status.streamId
451
+ val resolvedConfig: StreamResolvedConfig? get() = status.resolvedConfig
327
452
  val values: Map<String, Any> get() = status.toEventMap()
328
453
  }
329
454
 
@@ -0,0 +1,105 @@
1
+ package com.mentra.bluetoothsdk.utils;
2
+
3
+ import android.util.Log;
4
+
5
+ import java.util.Map;
6
+ import java.util.concurrent.ConcurrentHashMap;
7
+
8
+ /**
9
+ * Reassembles compact JSON chunks from the glasses before normal event dispatch.
10
+ */
11
+ public class MessageChunkReassembler {
12
+ private static final String TAG = "MessageChunkReassembler";
13
+ private static final long CHUNK_TIMEOUT_MS = 30000;
14
+ private static final int MAX_CONCURRENT_SESSIONS = 10;
15
+
16
+ private final Map<String, ChunkSession> activeSessions = new ConcurrentHashMap<>();
17
+
18
+ public String addChunk(String chunkId, int chunkIndex, int totalChunks, String data) {
19
+ cleanupTimedOutSessions();
20
+
21
+ if (activeSessions.size() >= MAX_CONCURRENT_SESSIONS && !activeSessions.containsKey(chunkId)) {
22
+ Log.w(TAG, "Maximum concurrent chunk sessions reached, dropping oldest");
23
+ removeOldestSession();
24
+ }
25
+
26
+ ChunkSession session = activeSessions.computeIfAbsent(chunkId, ignored -> new ChunkSession(chunkId, totalChunks));
27
+ boolean added = session.addChunk(chunkIndex, data);
28
+ if (!added) {
29
+ Log.w(TAG, "Failed to add chunk " + chunkIndex + " to session " + chunkId);
30
+ return null;
31
+ }
32
+
33
+ Log.d(TAG, "Added chunk " + chunkIndex + "/" + (totalChunks - 1) + " for session " + chunkId);
34
+
35
+ if (!session.isComplete()) {
36
+ return null;
37
+ }
38
+
39
+ String reassembled = session.reassemble();
40
+ activeSessions.remove(chunkId);
41
+ Log.d(TAG, "Reassembled message of " + reassembled.length() + " bytes from " + totalChunks + " chunks");
42
+ return reassembled;
43
+ }
44
+
45
+ public void clear() {
46
+ activeSessions.clear();
47
+ }
48
+
49
+ private void cleanupTimedOutSessions() {
50
+ long now = System.currentTimeMillis();
51
+ activeSessions.entrySet().removeIf(entry -> now - entry.getValue().createdAt > CHUNK_TIMEOUT_MS);
52
+ }
53
+
54
+ private void removeOldestSession() {
55
+ String oldestId = null;
56
+ long oldestTime = Long.MAX_VALUE;
57
+ for (Map.Entry<String, ChunkSession> entry : activeSessions.entrySet()) {
58
+ if (entry.getValue().createdAt < oldestTime) {
59
+ oldestTime = entry.getValue().createdAt;
60
+ oldestId = entry.getKey();
61
+ }
62
+ }
63
+ if (oldestId != null) {
64
+ activeSessions.remove(oldestId);
65
+ }
66
+ }
67
+
68
+ private static class ChunkSession {
69
+ final String chunkId;
70
+ final int totalChunks;
71
+ final long createdAt;
72
+ final Map<Integer, String> chunks;
73
+
74
+ ChunkSession(String chunkId, int totalChunks) {
75
+ this.chunkId = chunkId;
76
+ this.totalChunks = totalChunks;
77
+ this.createdAt = System.currentTimeMillis();
78
+ this.chunks = new ConcurrentHashMap<>();
79
+ }
80
+
81
+ boolean addChunk(int index, String data) {
82
+ if (index < 0 || index >= totalChunks) {
83
+ return false;
84
+ }
85
+ chunks.put(index, data);
86
+ return true;
87
+ }
88
+
89
+ boolean isComplete() {
90
+ return chunks.size() == totalChunks;
91
+ }
92
+
93
+ String reassemble() {
94
+ StringBuilder reassembled = new StringBuilder();
95
+ for (int i = 0; i < totalChunks; i++) {
96
+ String chunk = chunks.get(i);
97
+ if (chunk == null) {
98
+ throw new IllegalStateException("Missing chunk " + i + " for session " + chunkId);
99
+ }
100
+ reassembled.append(chunk);
101
+ }
102
+ return reassembled.toString();
103
+ }
104
+ }
105
+ }