@mentra/bluetooth-sdk 0.1.19 → 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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +135 -51
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +1 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +12 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +13 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +113 -147
  10. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
  11. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -8
  12. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +6 -5
  13. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +16 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
  17. package/build/BluetoothSdk.types.d.ts +25 -0
  18. package/build/BluetoothSdk.types.d.ts.map +1 -1
  19. package/build/BluetoothSdk.types.js.map +1 -1
  20. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  21. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  22. package/build/_private/BluetoothSdkModule.js.map +1 -1
  23. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  24. package/build/_private/photoRequestPayload.js +1 -0
  25. package/build/_private/photoRequestPayload.js.map +1 -1
  26. package/build/index.d.ts +1 -1
  27. package/build/index.d.ts.map +1 -1
  28. package/build/index.js +5 -0
  29. package/build/index.js.map +1 -1
  30. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  31. package/ios/Source/DeviceManager.swift +2 -1
  32. package/ios/Source/DeviceStore.swift +5 -0
  33. package/ios/Source/MentraBluetoothSDK.swift +14 -1
  34. package/ios/Source/camera/CameraModels.swift +17 -3
  35. package/ios/Source/sgcs/G1.swift +1 -1
  36. package/ios/Source/sgcs/MentraLive.swift +140 -17
  37. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  38. package/ios/Source/utils/G1Text.swift +30 -1
  39. package/package.json +1 -1
  40. package/src/BluetoothSdk.types.ts +26 -0
  41. package/src/_private/BluetoothSdkModule.ts +4 -0
  42. package/src/_private/photoRequestPayload.ts +1 -0
  43. package/src/index.ts +6 -0
@@ -25,6 +25,7 @@ import android.content.pm.PackageManager
25
25
  import android.graphics.Bitmap
26
26
  import android.os.Build
27
27
  import android.os.Handler
28
+ import android.os.HandlerThread
28
29
  import android.os.Looper
29
30
  import android.util.Log
30
31
  import androidx.core.app.ActivityCompat
@@ -99,6 +100,7 @@ class MentraLive : SGCManager() {
99
100
  // PSM. When the phone opens the channel, the glasses send file packets over it instead
100
101
  // of GATT FILE_READ notifications. Phone→glasses traffic stays on GATT.
101
102
  private const val L2CAP_FILE_PSM = 0x00C9
103
+ private const val FILE_PACKET_LOG_INTERVAL = 32
102
104
 
103
105
  // BLE UUIDs - updated to match K900 BES2800 MCU UUIDs for compatibility with both glass
104
106
  // types
@@ -216,6 +218,7 @@ class MentraLive : SGCManager() {
216
218
  // (or a v2 binary handshake succeeds, which implies wire-v2 LE).
217
219
  private var peerK900Le = false
218
220
  private var peerWireCapsBinary = false
221
+ private var peerFilePayloadV2 = false
219
222
  // Note: appVersion, buildNumber, deviceModel, androidVersion
220
223
  // are inherited from SGCManager parent class
221
224
 
@@ -242,6 +245,9 @@ class MentraLive : SGCManager() {
242
245
  private var lc3ReadCharacteristic: BluetoothGattCharacteristic? = null
243
246
  private var lc3WriteCharacteristic: BluetoothGattCharacteristic? = null
244
247
  private var handler = Handler(Looper.getMainLooper())
248
+ private val fileProcessingThread =
249
+ HandlerThread("MentraLive-FileProcessing").apply { start() }
250
+ private val fileProcessingHandler = Handler(fileProcessingThread.looper)
245
251
  private var scheduler: ScheduledExecutorService? = null
246
252
  private var isScanning = false
247
253
  private var isConnecting = false
@@ -321,7 +327,7 @@ class MentraLive : SGCManager() {
321
327
  private var activeFileTransfers = ConcurrentHashMap<String, FileTransferSession>()
322
328
 
323
329
  // BLE photo transfer tracking
324
- private var blePhotoTransfers: MutableMap<String, BlePhotoTransfer> = HashMap()
330
+ private var blePhotoTransfers: MutableMap<String, BlePhotoTransfer> = ConcurrentHashMap()
325
331
 
326
332
  /** Expected incident log relay files from glasses (B… firmware, L… logcat). */
327
333
  private val bleIncidentLogRelays = ConcurrentHashMap<String, BleIncidentLogRelay>()
@@ -780,6 +786,7 @@ class MentraLive : SGCManager() {
780
786
  wireHandshakeQueued = false
781
787
  peerK900Le = false
782
788
  peerWireCapsBinary = false
789
+ peerFilePayloadV2 = false
783
790
  BleJsonCompact.resetSession()
784
791
  }
785
792
  return
@@ -814,6 +821,7 @@ class MentraLive : SGCManager() {
814
821
  wireHandshakeQueued = false
815
822
  peerK900Le = false
816
823
  peerWireCapsBinary = false
824
+ peerFilePayloadV2 = false
817
825
  BleJsonCompact.resetSession()
818
826
  // Drop OTA caches when fully disconnected — avoids leaking session/step state
819
827
  // from a previous pairing into the next one.
@@ -1440,6 +1448,15 @@ class MentraLive : SGCManager() {
1440
1448
  )
1441
1449
  }
1442
1450
 
1451
+ private fun phyLabel(phy: Int): String {
1452
+ return when (phy) {
1453
+ BluetoothDevice.PHY_LE_1M -> "1M"
1454
+ BluetoothDevice.PHY_LE_2M -> "2M"
1455
+ BluetoothDevice.PHY_LE_CODED -> "coded"
1456
+ else -> "unknown($phy)"
1457
+ }
1458
+ }
1459
+
1443
1460
  /** GATT callback for BLE operations */
1444
1461
  private val gattCallback: BluetoothGattCallback =
1445
1462
  object : BluetoothGattCallback() {
@@ -1463,25 +1480,6 @@ class MentraLive : SGCManager() {
1463
1480
  isConnected = true
1464
1481
  connectedDevice = gatt.device
1465
1482
 
1466
- // High-priority connection interval (~11-15ms vs 30-50ms
1467
- // default) and 2M PHY, as MentraNex and G2 already do. The
1468
- // BES firmware only *prefers* 2M PHY; the central has to
1469
- // request the switch or the link stays on 1M.
1470
- try {
1471
- gatt.requestConnectionPriority(
1472
- BluetoothGatt.CONNECTION_PRIORITY_HIGH
1473
- )
1474
- gatt.setPreferredPhy(
1475
- BluetoothDevice.PHY_LE_2M_MASK,
1476
- BluetoothDevice.PHY_LE_2M_MASK,
1477
- BluetoothDevice.PHY_OPTION_NO_PREFERRED
1478
- )
1479
- } catch (e: SecurityException) {
1480
- Bridge.log(
1481
- "LIVE: requestConnectionPriority/setPreferredPhy denied: ${e.message}"
1482
- )
1483
- }
1484
-
1485
1483
  DeviceStore.apply("glasses", "bluetoothName", connectedDevice!!.name)
1486
1484
  // Persist MAC so reconnection can use direct GATT instead of scanning
1487
1485
  if (connectedDevice!!.address != null) {
@@ -1580,6 +1578,8 @@ class MentraLive : SGCManager() {
1580
1578
 
1581
1579
  // Close the L2CAP file channel (if the fast path was open)
1582
1580
  closeL2capFileChannel()
1581
+ fileProcessingHandler.removeCallbacksAndMessages(null)
1582
+ clearFilePacketBuffer()
1583
1583
 
1584
1584
  // Clean up GATT resources
1585
1585
  closeGattQuietly(false)
@@ -1635,6 +1635,8 @@ class MentraLive : SGCManager() {
1635
1635
 
1636
1636
  // Close the L2CAP file channel (if the fast path was open)
1637
1637
  closeL2capFileChannel()
1638
+ fileProcessingHandler.removeCallbacksAndMessages(null)
1639
+ clearFilePacketBuffer()
1638
1640
 
1639
1641
  // Clean up resources
1640
1642
  closeGattQuietly(false)
@@ -1647,6 +1649,38 @@ class MentraLive : SGCManager() {
1647
1649
  }
1648
1650
  }
1649
1651
 
1652
+ override fun onPhyUpdate(
1653
+ gatt: BluetoothGatt,
1654
+ txPhy: Int,
1655
+ rxPhy: Int,
1656
+ status: Int
1657
+ ) {
1658
+ Bridge.log(
1659
+ "LIVE: PHY update tx=" +
1660
+ phyLabel(txPhy) +
1661
+ ", rx=" +
1662
+ phyLabel(rxPhy) +
1663
+ ", status=" +
1664
+ status
1665
+ )
1666
+ }
1667
+
1668
+ override fun onPhyRead(
1669
+ gatt: BluetoothGatt,
1670
+ txPhy: Int,
1671
+ rxPhy: Int,
1672
+ status: Int
1673
+ ) {
1674
+ Bridge.log(
1675
+ "LIVE: PHY read tx=" +
1676
+ phyLabel(txPhy) +
1677
+ ", rx=" +
1678
+ phyLabel(rxPhy) +
1679
+ ", status=" +
1680
+ status
1681
+ )
1682
+ }
1683
+
1650
1684
  override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
1651
1685
  if (status == BluetoothGatt.GATT_SUCCESS) {
1652
1686
  Bridge.log("LIVE: GATT services discovered")
@@ -2631,18 +2665,6 @@ class MentraLive : SGCManager() {
2631
2665
  var iterations = 0
2632
2666
  val MAX_ITERATIONS = 100
2633
2667
 
2634
- // Debug: Log hex dump of first 40 bytes
2635
- val hexFirst = StringBuilder()
2636
- for (i in 0 until Math.min(40, filePacketBufferSize)) {
2637
- hexFirst.append(String.format("%02X ", filePacketBuffer[i]))
2638
- }
2639
- Bridge.log(
2640
- "LIVE: 📦 extractCompleteFilePackets: buffer has " +
2641
- filePacketBufferSize +
2642
- " bytes, first 40: " +
2643
- hexFirst.toString()
2644
- )
2645
-
2646
2668
  while (pos < filePacketBufferSize && iterations++ < MAX_ITERATIONS) {
2647
2669
  // Find start marker ## (0x23 0x23)
2648
2670
  var startPos = -1
@@ -2677,11 +2699,6 @@ class MentraLive : SGCManager() {
2677
2699
 
2678
2700
  // Need at least 5 bytes to read type and packSize: ## (2) + type (1) + packSize (2)
2679
2701
  if (filePacketBufferSize - pos < 5) {
2680
- Bridge.log(
2681
- "LIVE: 📦 Not enough data for header, have " +
2682
- (filePacketBufferSize - pos) +
2683
- " bytes, need 5"
2684
- )
2685
2702
  break
2686
2703
  }
2687
2704
 
@@ -2691,33 +2708,9 @@ class MentraLive : SGCManager() {
2691
2708
  ((filePacketBuffer[packSizeOffset].toInt() and 0xFF) shl 8) or
2692
2709
  (filePacketBuffer[packSizeOffset + 1].toInt() and 0xFF)
2693
2710
 
2694
- // Also try little-endian for comparison
2695
- val packSizeLE =
2696
- (filePacketBuffer[packSizeOffset].toInt() and 0xFF) or
2697
- ((filePacketBuffer[packSizeOffset + 1].toInt() and 0xFF) shl 8)
2698
- Bridge.log(
2699
- "LIVE: 📦 Header bytes 3-4: 0x" +
2700
- String.format(
2701
- "%02X%02X",
2702
- filePacketBuffer[packSizeOffset],
2703
- filePacketBuffer[packSizeOffset + 1]
2704
- ) +
2705
- " -> packSize BE=" +
2706
- packSize +
2707
- ", LE=" +
2708
- packSizeLE
2709
- )
2710
-
2711
2711
  // Validate packSize
2712
2712
  if (packSize < 0 || packSize > K900ProtocolUtils.FILE_PACK_SIZE) {
2713
- Log.w(
2714
- TAG,
2715
- "Invalid packSize " +
2716
- packSize +
2717
- " (LE would be " +
2718
- packSizeLE +
2719
- "), skipping start marker"
2720
- )
2713
+ Log.w(TAG, "Invalid packSize $packSize, skipping start marker")
2721
2714
  pos = startPos + 1
2722
2715
  continue
2723
2716
  }
@@ -2730,17 +2723,7 @@ class MentraLive : SGCManager() {
2730
2723
  // Check if we have the complete packet
2731
2724
  val availableBytes = filePacketBufferSize - pos
2732
2725
  if (availableBytes < expectedPacketSize) {
2733
- // Not enough data yet, wait for more fragments
2734
- Bridge.log(
2735
- "LIVE: 📦 Waiting for more data: have " +
2736
- availableBytes +
2737
- " of " +
2738
- expectedPacketSize +
2739
- " bytes (packSize=" +
2740
- packSize +
2741
- ")"
2742
- )
2743
- break // IMPORTANT: break here, don't continue looking for end marker
2726
+ break
2744
2727
  }
2745
2728
 
2746
2729
  // Verify end marker $$ at expected position
@@ -2748,25 +2731,6 @@ class MentraLive : SGCManager() {
2748
2731
  val endByte1 = filePacketBuffer[endMarkerPos]
2749
2732
  val endByte2 = filePacketBuffer[endMarkerPos + 1]
2750
2733
 
2751
- // Debug: Show bytes around expected end marker position
2752
- val endContext = StringBuilder()
2753
- for (i in
2754
- Math.max(0, endMarkerPos - 5)..Math.min(
2755
- filePacketBufferSize - 1,
2756
- endMarkerPos + 5
2757
- )) {
2758
- if (i == endMarkerPos) endContext.append("[")
2759
- endContext.append(String.format("%02X", filePacketBuffer[i]))
2760
- if (i == endMarkerPos + 1) endContext.append("]")
2761
- endContext.append(" ")
2762
- }
2763
- Bridge.log(
2764
- "LIVE: 📦 End marker check at pos " +
2765
- endMarkerPos +
2766
- ": " +
2767
- endContext.toString()
2768
- )
2769
-
2770
2734
  if (endByte1 != 0x24.toByte() || endByte2 != 0x24.toByte()) {
2771
2735
  // End marker not found - could be corrupted packet or wrong packSize interpretation
2772
2736
  Log.w(
@@ -2791,22 +2755,10 @@ class MentraLive : SGCManager() {
2791
2755
  val completePacket = ByteArray(expectedPacketSize)
2792
2756
  System.arraycopy(filePacketBuffer, pos, completePacket, 0, expectedPacketSize)
2793
2757
 
2794
- Bridge.log(
2795
- "LIVE: 📦 ✅ Complete file packet reassembled: " + expectedPacketSize + " bytes"
2796
- )
2797
-
2798
2758
  // Process the complete packet
2799
2759
  val packetInfo = K900ProtocolUtils.extractFilePacket(completePacket)
2800
2760
  if (packetInfo != null && packetInfo.isValid) {
2801
- Bridge.log(
2802
- "LIVE: 📦 ✅ Packet validated: index=" +
2803
- packetInfo.packIndex +
2804
- ", fileName=" +
2805
- packetInfo.fileName
2806
- )
2807
- // Post to handler to process outside the lock
2808
- val finalPacketInfo = packetInfo
2809
- handler.post { processFilePacket(finalPacketInfo) }
2761
+ enqueueFilePacket(packetInfo)
2810
2762
  } else {
2811
2763
  Log.e(TAG, "Failed to extract/validate reassembled file packet")
2812
2764
  }
@@ -2819,13 +2771,6 @@ class MentraLive : SGCManager() {
2819
2771
  val remaining = filePacketBufferSize - pos
2820
2772
  System.arraycopy(filePacketBuffer, pos, filePacketBuffer, 0, remaining)
2821
2773
  filePacketBufferSize = remaining
2822
- Bridge.log(
2823
- "LIVE: 📦 Removed " +
2824
- pos +
2825
- " bytes, " +
2826
- remaining +
2827
- " bytes remaining in buffer"
2828
- )
2829
2774
  } else if (pos >= filePacketBufferSize) {
2830
2775
  filePacketBufferSize = 0
2831
2776
  }
@@ -2942,7 +2887,7 @@ class MentraLive : SGCManager() {
2942
2887
  // structure
2943
2888
  val packetInfo = K900ProtocolUtils.extractFilePacket(data)
2944
2889
  if (packetInfo != null && packetInfo.isValid) {
2945
- processFilePacket(packetInfo)
2890
+ enqueueFilePacket(packetInfo)
2946
2891
  } else {
2947
2892
  Log.e(TAG, "Thread-" + threadId + ": Failed to extract or validate file packet")
2948
2893
  // BES chip handles ACKs automatically
@@ -3559,6 +3504,7 @@ class MentraLive : SGCManager() {
3559
3504
 
3560
3505
  // Stop the readiness check loop since we got confirmation
3561
3506
  stopReadinessCheckLoop()
3507
+ advertiseFilePayloadCapabilityToBes()
3562
3508
 
3563
3509
  // Try to open the L2CAP CoC fast path for file transfers. No-op when the
3564
3510
  // firmware doesn't support it — GATT notifications remain the default path.
@@ -5431,6 +5377,7 @@ class MentraLive : SGCManager() {
5431
5377
  override fun requestPhoto(request: PhotoRequest) {
5432
5378
  val requestId = request.requestId
5433
5379
  val size = request.size.value
5380
+ val mode = request.mode.value
5434
5381
  val webhookUrl = request.webhookUrl
5435
5382
  val authToken = request.authToken
5436
5383
  val compress = request.compress.value
@@ -5444,6 +5391,8 @@ class MentraLive : SGCManager() {
5444
5391
  requestId +
5445
5392
  " with size: " +
5446
5393
  size +
5394
+ ", mode=" +
5395
+ mode +
5447
5396
  ", webhookUrl: " +
5448
5397
  webhookUrl +
5449
5398
  ", authToken: " +
@@ -5481,6 +5430,7 @@ class MentraLive : SGCManager() {
5481
5430
  if (size != null && !size.isEmpty()) {
5482
5431
  json.put("size", size)
5483
5432
  }
5433
+ json.put("mode", mode)
5484
5434
  if (compress != null && !compress.isEmpty()) {
5485
5435
  json.put("compress", compress)
5486
5436
  } else {
@@ -5508,7 +5458,7 @@ class MentraLive : SGCManager() {
5508
5458
  val bleImgId = "I" + String.format("%09d", System.currentTimeMillis() % 1000000000)
5509
5459
  json.put("bleImgId", bleImgId)
5510
5460
 
5511
- // Use auto mode by default - glasses will decide based on connectivity
5461
+ // Use auto mode by default - glasses try WiFi direct upload, BLE fallback via bleImgId
5512
5462
  json.put("transferMethod", "auto")
5513
5463
 
5514
5464
  // Always prepare for potential BLE transfer
@@ -5521,7 +5471,9 @@ class MentraLive : SGCManager() {
5521
5471
 
5522
5472
  Bridge.log("LIVE: Using auto transfer mode with BLE fallback ID: " + bleImgId)
5523
5473
  Bridge.log(
5524
- "LIVE: PHOTO PIPELINE [5b/6] JSON ready " +
5474
+ "LIVE: PHOTO PIPELINE [5b/6] JSON ready mode=" +
5475
+ mode +
5476
+ " — " +
5525
5477
  summarizeOutgoingMessage(json.toString()) +
5526
5478
  ", wakeup=true"
5527
5479
  )
@@ -6178,6 +6130,7 @@ class MentraLive : SGCManager() {
6178
6130
 
6179
6131
  // Cancel any pending handlers
6180
6132
  handler.removeCallbacksAndMessages(null)
6133
+ fileProcessingHandler.removeCallbacksAndMessages(null)
6181
6134
  heartbeatHandler.removeCallbacksAndMessages(null)
6182
6135
  rssiReadHandler.removeCallbacksAndMessages(null)
6183
6136
  micBeatHandler.removeCallbacksAndMessages(null)
@@ -6209,6 +6162,7 @@ class MentraLive : SGCManager() {
6209
6162
 
6210
6163
  // Clear file packet reassembly buffer
6211
6164
  clearFilePacketBuffer()
6165
+ fileProcessingThread.quitSafely()
6212
6166
 
6213
6167
  // Reset state variables
6214
6168
  reconnectAttempts = 0
@@ -7219,6 +7173,32 @@ class MentraLive : SGCManager() {
7219
7173
  if (caps.has("binary")) {
7220
7174
  peerWireCapsBinary = caps.optBoolean("binary", false)
7221
7175
  }
7176
+ if (caps.has("file_payload_v2")) {
7177
+ peerFilePayloadV2 = caps.optBoolean("file_payload_v2", false)
7178
+ }
7179
+ }
7180
+
7181
+ private fun advertiseFilePayloadCapabilityToBes() {
7182
+ if (!peerFilePayloadV2) return
7183
+ try {
7184
+ val command = JSONObject()
7185
+ command.put("C", "cs_file_payload")
7186
+ command.put("B", "{\"max\":" + K900ProtocolUtils.FILE_PACK_SIZE + "}")
7187
+ val packed =
7188
+ K900ProtocolUtils.packDataToK900(
7189
+ command.toString().toByteArray(StandardCharsets.UTF_8),
7190
+ K900ProtocolUtils.CMD_TYPE_STRING,
7191
+ k900LengthEndian()
7192
+ )
7193
+ queueData(packed)
7194
+ Bridge.log(
7195
+ "LIVE: 📦 Advertised file payload ceiling " +
7196
+ K900ProtocolUtils.FILE_PACK_SIZE +
7197
+ " to BES"
7198
+ )
7199
+ } catch (e: JSONException) {
7200
+ Log.e(TAG, "Failed to advertise file payload capability", e)
7201
+ }
7222
7202
  }
7223
7203
 
7224
7204
  private fun maybeSendWireHandshake() {
@@ -7793,7 +7773,7 @@ class MentraLive : SGCManager() {
7793
7773
 
7794
7774
  private fun summarizeOutgoingMessage(payload: String?): String {
7795
7775
  if (payload == null || payload.isEmpty()) {
7796
- return "type=unknown, requestId=none, appId=none, transferMethod=none, bleImgId=none, exposureTimeNs=none, iso=none, mId=none"
7776
+ return "type=unknown, requestId=none, appId=none, mode=none, transferMethod=none, bleImgId=none, exposureTimeNs=none, iso=none, mId=none"
7797
7777
  }
7798
7778
  try {
7799
7779
  val obj = JSONObject(payload)
@@ -7802,6 +7782,7 @@ class MentraLive : SGCManager() {
7802
7782
  val appId = obj.optString("appId", "none")
7803
7783
  val transferMethod = obj.optString("transferMethod", "none")
7804
7784
  val bleImgId = obj.optString("bleImgId", "none")
7785
+ val mode = if (obj.has("mode")) obj.optString("mode", "photo") else "none"
7805
7786
  val exposure =
7806
7787
  if (obj.has("exposureTimeNs")) obj.optLong("exposureTimeNs").toString()
7807
7788
  else "none"
@@ -7817,6 +7798,8 @@ class MentraLive : SGCManager() {
7817
7798
  transferMethod +
7818
7799
  ", bleImgId=" +
7819
7800
  bleImgId +
7801
+ ", mode=" +
7802
+ mode +
7820
7803
  ", exposureTimeNs=" +
7821
7804
  exposure +
7822
7805
  ", iso=" +
@@ -8126,6 +8109,11 @@ class MentraLive : SGCManager() {
8126
8109
  // File Transfer Methods
8127
8110
  // ---------------------------------------
8128
8111
 
8112
+ /** Keep file assembly ordered without blocking BLE callbacks or the Android main looper. */
8113
+ private fun enqueueFilePacket(packetInfo: K900ProtocolUtils.FilePacketInfo) {
8114
+ fileProcessingHandler.post { processFilePacket(packetInfo) }
8115
+ }
8116
+
8129
8117
  /** Process a received file packet */
8130
8118
  private fun processFilePacket(packetInfo: K900ProtocolUtils.FilePacketInfo) {
8131
8119
  // Calculate total packets based on actual pack size (not hardcoded FILE_PACK_SIZE)
@@ -8133,18 +8121,15 @@ class MentraLive : SGCManager() {
8133
8121
  if (packetInfo.packSize > 0)
8134
8122
  (packetInfo.fileSize + packetInfo.packSize - 1) / packetInfo.packSize
8135
8123
  else 1
8136
- Bridge.log(
8137
- "LIVE: 📦 Processing file packet: " +
8138
- packetInfo.fileName +
8139
- " [" +
8140
- packetInfo.packIndex +
8141
- "/" +
8142
- (totalPackets - 1) +
8143
- "]" +
8144
- " (" +
8145
- packetInfo.packSize +
8146
- " bytes)"
8147
- )
8124
+ if (packetInfo.packIndex % FILE_PACKET_LOG_INTERVAL == 0 ||
8125
+ packetInfo.packIndex == totalPackets - 1
8126
+ ) {
8127
+ Log.d(
8128
+ TAG,
8129
+ "File transfer ${packetInfo.fileName}: " +
8130
+ "${packetInfo.packIndex + 1}/$totalPackets packets"
8131
+ )
8132
+ }
8148
8133
 
8149
8134
  // Check if this is a BLE photo transfer we're tracking
8150
8135
  // The filename might have an extension (.avif or .jpg), but we track by ID only
@@ -8154,8 +8139,6 @@ class MentraLive : SGCManager() {
8154
8139
  bleImgId = bleImgId.substring(0, dotIndex)
8155
8140
  }
8156
8141
 
8157
- Bridge.log("LIVE: 📦 BLE photo transfer packet for requestId: " + bleImgId)
8158
-
8159
8142
  val incidentRelay = bleIncidentLogRelays[bleImgId]
8160
8143
  if (incidentRelay != null) {
8161
8144
  Bridge.log("LIVE: 📦 BLE incident log relay packet for: " + bleImgId)
@@ -8209,18 +8192,8 @@ class MentraLive : SGCManager() {
8209
8192
  }
8210
8193
 
8211
8194
  val photoTransfer = blePhotoTransfers[bleImgId]
8212
- Bridge.log(
8213
- "LIVE: 📦 BLE photo transfer for requestId: " +
8214
- bleImgId +
8215
- " found: " +
8216
- (photoTransfer != null)
8217
- )
8218
8195
  if (photoTransfer != null) {
8219
8196
  // This is a BLE photo transfer
8220
- Bridge.log(
8221
- "LIVE: 📦 BLE photo transfer packet for requestId: " + photoTransfer.requestId
8222
- )
8223
-
8224
8197
  // Get or create session for this transfer
8225
8198
  if (photoTransfer.session == null) {
8226
8199
  photoTransfer.session =
@@ -8332,13 +8305,6 @@ class MentraLive : SGCManager() {
8332
8305
  val added = session.addPacket(packetInfo.packIndex, packetInfo.data)
8333
8306
 
8334
8307
  if (added) {
8335
- // BES chip handles ACKs automatically
8336
- Bridge.log(
8337
- "LIVE: 📦 Packet " +
8338
- packetInfo.packIndex +
8339
- " received successfully (BES will auto-ACK)"
8340
- )
8341
-
8342
8308
  // Check completion when final packet arrives or transfer is complete
8343
8309
  if (session.shouldCheckCompletion(packetInfo.packIndex)) {
8344
8310
  if (session.isComplete) {
@@ -2,7 +2,6 @@ package com.mentra.bluetoothsdk
2
2
 
3
3
  import com.mentra.bluetoothsdk.utils.ControllerTypes
4
4
  import com.mentra.bluetoothsdk.utils.DeviceTypes
5
- import expo.modules.kotlin.exception.CodedException
6
5
 
7
6
  data class MentraBluetoothSdkConfig @JvmOverloads constructor(
8
7
  val deliverCallbacksOnMainThread: Boolean = true,
@@ -10,10 +9,10 @@ data class MentraBluetoothSdkConfig @JvmOverloads constructor(
10
9
  )
11
10
 
12
11
  class BluetoothSdkException(
13
- code: String,
12
+ val code: String,
14
13
  message: String,
15
14
  cause: Throwable? = null,
16
- ) : CodedException(code, message, cause)
15
+ ) : IllegalStateException(message, cause)
17
16
 
18
17
  enum class DeviceModel(val deviceType: String) {
19
18
  G1(DeviceTypes.G1),
@@ -37,10 +37,10 @@ import org.json.JSONObject;
37
37
  */
38
38
  public class BlePhotoUploadService {
39
39
  private static final String TAG = "BlePhotoUploadService";
40
- // 100: the source already went through a lossy AVIF pass on the glasses, so
41
- // this re-encode must not compound the loss. Phone CPU and upload bandwidth
42
- // are cheap relative to what was paid to get the bytes over BLE.
43
- private static final int JPEG_QUALITY = 100;
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;
44
44
 
45
45
  public interface UploadCallback {
46
46
  void onSuccess(String requestId, String responseBody);
@@ -84,6 +84,18 @@ public class BlePhotoUploadService {
84
84
  */
85
85
  @VisibleForTesting
86
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
+
87
99
  File inputFile = File.createTempFile("ble_photo_in_", guessExtension(imageData));
88
100
  File outputFile = File.createTempFile("ble_photo_out_", ".jpg");
89
101
  try {
@@ -95,19 +107,39 @@ public class BlePhotoUploadService {
95
107
 
96
108
  String imuJson = readImuJsonFromBleImage(imageData, inputFile.getAbsolutePath());
97
109
 
110
+ long decodeStartMs = System.currentTimeMillis();
98
111
  Bitmap bitmap = decodeImage(imageData);
112
+ long decodeDurationMs = System.currentTimeMillis() - decodeStartMs;
99
113
  if (bitmap == null) {
100
114
  throw new Exception("Failed to decode image data");
101
115
  }
102
116
 
103
- Log.d(TAG, "Decoded image to bitmap: " + bitmap.getWidth() + "x" + bitmap.getHeight());
104
-
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();
105
128
  try (FileOutputStream fos = new FileOutputStream(outputFile)) {
106
- bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, fos);
129
+ bitmap.compress(Bitmap.CompressFormat.JPEG, AVIF_TO_JPEG_QUALITY, fos);
107
130
  } finally {
108
131
  bitmap.recycle();
109
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");
110
141
 
142
+ long metadataStartMs = System.currentTimeMillis();
111
143
  if (imuJson != null && !imuJson.isEmpty()) {
112
144
  logImuData(imuJson);
113
145
  // IMU EXIF is enrichment, not the payload: a write failure must not drop the
@@ -131,8 +163,27 @@ public class BlePhotoUploadService {
131
163
  + "). If rawHasExifMarker=true, EXIF may be present but unreadable via"
132
164
  + " ExifInterface on this container.");
133
165
  }
166
+ Log.d(
167
+ TAG,
168
+ "AVIF metadata handling complete in "
169
+ + (System.currentTimeMillis() - metadataStartMs)
170
+ + "ms");
134
171
 
135
- return java.nio.file.Files.readAllBytes(outputFile.toPath());
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;
136
187
  } finally {
137
188
  if (!inputFile.delete()) {
138
189
  inputFile.deleteOnExit();
@@ -254,6 +305,12 @@ public class BlePhotoUploadService {
254
305
  exif.saveAttributes();
255
306
  }
256
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
+
257
314
  private static String guessExtension(byte[] imageData) {
258
315
  if (imageData.length > 12
259
316
  && imageData[4] == 'f'