@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.
- package/README.md +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +135 -51
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +5 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +12 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +13 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +113 -147
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +2 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +65 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +6 -5
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +16 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +9 -0
- package/build/BluetoothSdk.types.d.ts +25 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +4 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +1 -0
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +5 -0
- package/build/index.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +2 -1
- package/ios/Source/DeviceStore.swift +5 -0
- package/ios/Source/MentraBluetoothSDK.swift +14 -1
- package/ios/Source/camera/CameraModels.swift +17 -3
- package/ios/Source/sgcs/G1.swift +1 -1
- package/ios/Source/sgcs/MentraLive.swift +140 -17
- package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
- package/ios/Source/utils/G1Text.swift +30 -1
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +26 -0
- package/src/_private/BluetoothSdkModule.ts +4 -0
- package/src/_private/photoRequestPayload.ts +1 -0
- 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> =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
8137
|
-
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
8142
|
-
|
|
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
|
-
) :
|
|
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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
private static final int
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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'
|