@mentra/bluetooth-sdk 3.3.0-dev.513 → 3.3.0-dev.514

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.
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "3.3.0"
6
- const val RELEASE_IDENTITY: String = "3.3.0-dev.513"
7
- const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.513"
8
- const val SOURCE_COMMIT: String = "abeb5904b244689cdfee105ac4eed26629df40ac"
9
- const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.513.json"
10
- const val OTA_MANIFEST_SHA256: String = "bc98768e1baee50bdb955ef230822f4070e0c20bc910d1960a1b2e4307a0f077"
6
+ const val RELEASE_IDENTITY: String = "3.3.0-dev.514"
7
+ const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.514"
8
+ const val SOURCE_COMMIT: String = "c08afa1e430e63dffd758478435e8d59940d36c7"
9
+ const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.514.json"
10
+ const val OTA_MANIFEST_SHA256: String = "24a37a482d08ef1a11ff5c96280c641150601b255e336f7723dbf88cb598337e"
11
11
  }
@@ -777,7 +777,8 @@ class MentraLive : SGCManager() {
777
777
  }
778
778
 
779
779
  // Inner class to track incoming file transfers
780
- private class FileTransferSession(var fileName: String, var fileSize: Int) {
780
+ internal class FileTransferSession(var fileName: String, var fileSize: Int, flags: Int = 0) {
781
+ private val dynamicPayload = (flags and 0x0002) != 0
781
782
  // NOTE: fileSize may be "fake" (inflated) due to BES firmware workaround
782
783
  var actualPackSize =
783
784
  0 // Actual pack size from first received packet (for BES lie detection)
@@ -806,9 +807,8 @@ class MentraLive : SGCManager() {
806
807
  * Recalculate total packets based on actual pack size from received packet. Called when
807
808
  * first packet is received to handle variable pack sizes.
808
809
  *
809
- * NOTE: Due to BES firmware workaround, fileSize in header may be "fake" (inflated). We
810
- * detect this by checking if fileSize is a multiple of 400 (BES_HARDCODED_PACK_SIZE). If
811
- * so, totalPackets = fileSize / 400, regardless of actual pack size.
810
+ * Dynamic-payload headers carry the true byte count. Only legacy small packs use
811
+ * the inflated-size workaround (multiples of BES_HARDCODED_PACK_SIZE).
812
812
  */
813
813
  fun recalculateTotalPackets(actualPackSize: Int) {
814
814
  if (actualPackSize <= 0 || actualPackSize > K900ProtocolUtils.FILE_PACK_SIZE) {
@@ -819,8 +819,8 @@ class MentraLive : SGCManager() {
819
819
 
820
820
  // Detect BES lie: if fileSize is exact multiple of 400, glasses used the lie strategy
821
821
  val isBesLie =
822
- (fileSize % BES_HARDCODED_PACK_SIZE == 0) &&
823
- (actualPackSize != BES_HARDCODED_PACK_SIZE)
822
+ !dynamicPayload && (fileSize % BES_HARDCODED_PACK_SIZE == 0) &&
823
+ (actualPackSize < BES_HARDCODED_PACK_SIZE)
824
824
 
825
825
  val newTotalPackets: Int
826
826
  if (isBesLie) {
@@ -858,6 +858,10 @@ class MentraLive : SGCManager() {
858
858
  }
859
859
 
860
860
  fun addPacket(index: Int, data: ByteArray): Boolean {
861
+ if (dynamicPayload && (index < 0 || index >= totalPackets || actualPackSize <= 0 ||
862
+ data.size != minOf(actualPackSize, fileSize - index * actualPackSize))) {
863
+ return false
864
+ }
861
865
  if (index >= 0 && index < totalPackets && !receivedPackets.containsKey(index)) {
862
866
  receivedPackets[index] = data
863
867
 
@@ -10235,7 +10239,7 @@ class MentraLive : SGCManager() {
10235
10239
  if (incidentRelay.session == null) {
10236
10240
  activeFileTransfers.remove(packetInfo.fileName)
10237
10241
  incidentRelay.session =
10238
- FileTransferSession(packetInfo.fileName, packetInfo.fileSize)
10242
+ FileTransferSession(packetInfo.fileName, packetInfo.fileSize, packetInfo.flags)
10239
10243
  incidentRelay.session!!.recalculateTotalPackets(packetInfo.packSize)
10240
10244
  Bridge.log(
10241
10245
  "LIVE: 📦 Started BLE incident log transfer: " +
@@ -10286,7 +10290,7 @@ class MentraLive : SGCManager() {
10286
10290
  // Get or create session for this transfer
10287
10291
  if (photoTransfer.session == null) {
10288
10292
  photoTransfer.session =
10289
- FileTransferSession(packetInfo.fileName, packetInfo.fileSize)
10293
+ FileTransferSession(packetInfo.fileName, packetInfo.fileSize, packetInfo.flags)
10290
10294
  // Recalculate total packets based on actual pack size (handles variable MTU)
10291
10295
  photoTransfer.session!!.recalculateTotalPackets(packetInfo.packSize)
10292
10296
  Bridge.log(
@@ -10372,7 +10376,7 @@ class MentraLive : SGCManager() {
10372
10376
  var session = activeFileTransfers[packetInfo.fileName]
10373
10377
  if (session == null) {
10374
10378
  // New file transfer
10375
- session = FileTransferSession(packetInfo.fileName, packetInfo.fileSize)
10379
+ session = FileTransferSession(packetInfo.fileName, packetInfo.fileSize, packetInfo.flags)
10376
10380
  // Recalculate total packets based on actual pack size (handles variable MTU)
10377
10381
  session.recalculateTotalPackets(packetInfo.packSize)
10378
10382
  activeFileTransfers[packetInfo.fileName] = session
@@ -0,0 +1,55 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+ import org.junit.runner.RunWith
6
+ import org.robolectric.RobolectricTestRunner
7
+ import org.robolectric.annotation.Config
8
+
9
+ @RunWith(RobolectricTestRunner::class)
10
+ @Config(sdk = [33])
11
+ class MentraLiveFileTransferSessionTest {
12
+ @Test fun dynamicPayloadUsesTrueSizeEvenWhenDivisibleBy400() {
13
+ for (pack in listOf(244, 474, 800)) {
14
+ val session = MentraLive.FileTransferSession("photo", 80000, 2)
15
+ session.recalculateTotalPackets(pack)
16
+ assertEquals((80000 + pack - 1) / pack, session.totalPackets)
17
+ for (index in 0 until session.totalPackets) {
18
+ assertTrue(session.addPacket(index, ByteArray(minOf(pack, 80000 - index * pack))))
19
+ }
20
+ assertTrue(session.isComplete)
21
+ assertEquals(80000, session.assembleFile()!!.size)
22
+ }
23
+ }
24
+
25
+ @Test fun legacyInflatedSizeAndLargeUnflaggedPayloads() {
26
+ // Flag 1 enables batched ACKs but still uses legacy size semantics.
27
+ for ((pack, headerSize, actualSize, packets) in listOf(
28
+ listOf(221, 80000, 43982, 200),
29
+ listOf(400, 80000, 79777, 200),
30
+ listOf(800, 80000, 80000, 100))) {
31
+ val session = MentraLive.FileTransferSession("photo", headerSize, 1)
32
+ session.recalculateTotalPackets(pack)
33
+ assertEquals(packets, session.totalPackets)
34
+ val source = ByteArray(actualSize) { (it % 251).toByte() }
35
+ for (index in (0 until packets).reversed()) {
36
+ val start = index * pack
37
+ assertTrue(session.addPacket(index, source.copyOfRange(start, minOf(start + pack, actualSize))))
38
+ }
39
+ assertTrue(session.isComplete)
40
+ assertArrayEquals(source, session.assembleFile())
41
+ }
42
+ }
43
+
44
+ @Test fun malformedOrDuplicatePacketsCannotCompleteFile() {
45
+ val session = MentraLive.FileTransferSession("photo", 1000, 2)
46
+ session.recalculateTotalPackets(800)
47
+ assertFalse(session.addPacket(0, ByteArray(799)))
48
+ assertFalse(session.addPacket(2, ByteArray(200)))
49
+ assertTrue(session.addPacket(1, ByteArray(200)))
50
+ assertFalse(session.isComplete)
51
+ assertFalse(session.addPacket(1, ByteArray(200)))
52
+ assertTrue(session.addPacket(0, ByteArray(800)))
53
+ assertEquals(1000, session.assembleFile()!!.size)
54
+ }
55
+ }
@@ -1,10 +1,10 @@
1
1
  export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
2
2
  "schemaVersion": 1,
3
3
  "familyBaseVersion": "3.3.0",
4
- "releaseIdentity": "3.3.0-dev.513",
5
- "releaseSetId": "mentra-3.3.0-dev.513",
6
- "sourceCommit": "abeb5904b244689cdfee105ac4eed26629df40ac",
7
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.513.json",
8
- "otaManifestSha256": "bc98768e1baee50bdb955ef230822f4070e0c20bc910d1960a1b2e4307a0f077"
4
+ "releaseIdentity": "3.3.0-dev.514",
5
+ "releaseSetId": "mentra-3.3.0-dev.514",
6
+ "sourceCommit": "c08afa1e430e63dffd758478435e8d59940d36c7",
7
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.514.json",
8
+ "otaManifestSha256": "24a37a482d08ef1a11ff5c96280c641150601b255e336f7723dbf88cb598337e"
9
9
  });
10
10
  //# sourceMappingURL=releaseMetadata.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.513\",\n \"releaseSetId\": \"mentra-3.3.0-dev.513\",\n \"sourceCommit\": \"abeb5904b244689cdfee105ac4eed26629df40ac\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.513.json\",\n \"otaManifestSha256\": \"bc98768e1baee50bdb955ef230822f4070e0c20bc910d1960a1b2e4307a0f077\"\n})\n"]}
1
+ {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.514\",\n \"releaseSetId\": \"mentra-3.3.0-dev.514\",\n \"sourceCommit\": \"c08afa1e430e63dffd758478435e8d59940d36c7\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.514.json\",\n \"otaManifestSha256\": \"24a37a482d08ef1a11ff5c96280c641150601b255e336f7723dbf88cb598337e\"\n})\n"]}
@@ -16,7 +16,7 @@ enum BluetoothSdkDefaults {
16
16
  /// silently disable it on every pair the app touches.
17
17
  static let autoPowerOffEnabled = false
18
18
  private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
19
- private static let swiftPackageSdkVersion = "3.3.0-dev.513"
19
+ private static let swiftPackageSdkVersion = "3.3.0-dev.514"
20
20
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
21
21
 
22
22
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -3,9 +3,9 @@ import Foundation
3
3
  /// Generated by release CI. Do not edit in a release checkout.
4
4
  enum GeneratedReleaseMetadata {
5
5
  static let familyBaseVersion = "3.3.0"
6
- static let releaseIdentity = "3.3.0-dev.513"
7
- static let releaseSetId = "mentra-3.3.0-dev.513"
8
- static let sourceCommit = "abeb5904b244689cdfee105ac4eed26629df40ac"
9
- static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.513.json"
10
- static let otaManifestSha256 = "bc98768e1baee50bdb955ef230822f4070e0c20bc910d1960a1b2e4307a0f077"
6
+ static let releaseIdentity = "3.3.0-dev.514"
7
+ static let releaseSetId = "mentra-3.3.0-dev.514"
8
+ static let sourceCommit = "c08afa1e430e63dffd758478435e8d59940d36c7"
9
+ static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.514.json"
10
+ static let otaManifestSha256 = "24a37a482d08ef1a11ff5c96280c641150601b255e336f7723dbf88cb598337e"
11
11
  }
@@ -677,145 +677,13 @@ enum K900ProtocolUtils {
677
677
  }
678
678
  }
679
679
 
680
- private struct FileTransferSession {
681
- let fileName: String
682
- let fileSize: Int // NOTE: May be "fake" (inflated) due to BES firmware workaround
683
- var actualPackSize: Int = 0 // Actual pack size from first received packet
684
- var totalPackets: Int
685
- var expectedNextPacket: Int = 0
686
- var receivedPackets: [Int: Data] = [:]
687
- let startTime: Date
688
- var isComplete: Bool = false
689
- var isAnnounced: Bool = false
690
-
691
- /// BES2700 firmware hardcodes FILE_PACK_SIZE=400 when calculating totalPack.
692
- /// Android glasses "lie" about fileSize to make BES expect correct packet count.
693
- private static let BES_HARDCODED_PACK_SIZE = 400
694
-
695
- init(fileName: String, fileSize: Int, announcedPackets: Int? = nil) {
696
- self.fileName = fileName
697
- self.fileSize = fileSize
698
- let computedPackets =
699
- (fileSize + K900ProtocolUtils.FILE_PACK_SIZE - 1) / K900ProtocolUtils.FILE_PACK_SIZE
700
- if let announced = announcedPackets, announced > 0 {
701
- totalPackets = announced
702
- isAnnounced = true
703
- } else {
704
- totalPackets = computedPackets
705
- isAnnounced = false
706
- }
707
- startTime = Date()
708
- }
709
-
710
- mutating func updateAnnouncedPackets(_ announced: Int) {
711
- guard announced > 0 else { return }
712
- totalPackets = announced
713
- isAnnounced = true
714
- if expectedNextPacket >= totalPackets {
715
- expectedNextPacket = min(expectedNextPacket, max(totalPackets - 1, 0))
716
- }
717
- }
718
-
719
- /// Recalculate total packets based on actual pack size from received packet.
720
- /// Detects BES lie: if fileSize is multiple of 400 but actual pack size differs.
721
- mutating func recalculateTotalPackets(actualPackSize: Int) {
722
- guard actualPackSize > 0, actualPackSize <= K900ProtocolUtils.FILE_PACK_SIZE else { return }
723
-
724
- self.actualPackSize = actualPackSize
725
-
726
- // Detect BES lie: if fileSize is exact multiple of 400, glasses used the lie strategy
727
- let isBesLie =
728
- (fileSize % Self.BES_HARDCODED_PACK_SIZE == 0)
729
- && (actualPackSize != Self.BES_HARDCODED_PACK_SIZE)
730
-
731
- let newTotalPackets: Int
732
- if isBesLie {
733
- // BES lie detected: totalPackets = fileSize / 400
734
- newTotalPackets = fileSize / Self.BES_HARDCODED_PACK_SIZE
735
- Bridge.log(
736
- "📦 BES Lie detected! fakeFileSize=\(fileSize), totalPackets=\(newTotalPackets), actualPackSize=\(actualPackSize)"
737
- )
738
- } else {
739
- // Normal case: calculate based on actual pack size
740
- newTotalPackets = (fileSize + actualPackSize - 1) / actualPackSize
741
- }
742
-
743
- if newTotalPackets != totalPackets {
744
- Bridge.log(
745
- "📦 Recalculating totalPackets: \(totalPackets) -> \(newTotalPackets) (packSize=\(actualPackSize), fileSize=\(fileSize))"
746
- )
747
- totalPackets = newTotalPackets
748
- }
749
- }
750
-
751
- mutating func addPacket(_ index: Int, data: Data) -> Bool {
752
- guard index >= 0 else { return false }
753
-
754
- // On first packet, recalculate total packets only when we do not already
755
- // have an authoritative pack size from protocol metadata.
756
- if receivedPackets.isEmpty && actualPackSize == 0 && !data.isEmpty {
757
- recalculateTotalPackets(actualPackSize: data.count)
758
- }
759
-
760
- if index >= totalPackets {
761
- totalPackets = index + 1
762
- }
763
-
764
- guard receivedPackets[index] == nil else {
765
- return false
766
- }
767
-
768
- receivedPackets[index] = data
769
-
770
- while receivedPackets[expectedNextPacket] != nil, expectedNextPacket < totalPackets {
771
- expectedNextPacket += 1
772
- }
773
-
774
- isComplete = (receivedPackets.count == totalPackets)
775
- return true
776
- }
777
-
778
- func isFinalPacket(_ index: Int) -> Bool {
779
- index == totalPackets - 1
780
- }
781
-
782
- func missingPacketIndices() -> [Int] {
783
- guard totalPackets > receivedPackets.count else { return [] }
784
- return (0 ..< totalPackets).compactMap { receivedPackets[$0] == nil ? $0 : nil }
785
- }
786
-
787
- /// Assemble file from received packets.
788
- /// NOTE: Calculates actual file size from received data, NOT from header fileSize,
789
- /// because fileSize may be "fake" (inflated) due to BES firmware workaround.
790
- func assembleFile() -> Data? {
791
- guard isComplete else { return nil }
792
-
793
- // Calculate actual file size by summing all received packet sizes
794
- let actualFileSize = receivedPackets.values.reduce(0) { $0 + $1.count }
795
-
796
- Bridge.log(
797
- "📦 Assembling file: headerFileSize=\(fileSize), actualFileSize=\(actualFileSize), totalPackets=\(totalPackets)"
798
- )
799
-
800
- var fileData = Data(capacity: actualFileSize)
801
-
802
- for i in 0 ..< totalPackets {
803
- if let packet = receivedPackets[i] {
804
- fileData.append(packet)
805
- }
806
- }
807
-
808
- return fileData
809
- }
810
- }
811
-
812
680
  private struct BlePhotoTransfer {
813
681
  var isThumbnail = false
814
682
  let bleImgId: String
815
683
  let requestId: String
816
684
  let webhookUrl: String
817
685
  var authToken: String?
818
- var session: FileTransferSession?
686
+ var session: MentraLiveFileTransferSession?
819
687
  let phoneStartTime: Date
820
688
  var bleTransferStartTime: Date?
821
689
  var glassesCompressionDurationMs: Int64 = 0
@@ -838,7 +706,7 @@ private final class BleIncidentLogRelayEntry {
838
706
  let incidentId: String
839
707
  let apiBaseUrl: String
840
708
  let kind: BleIncidentLogRelayKind
841
- var session: FileTransferSession?
709
+ var session: MentraLiveFileTransferSession?
842
710
 
843
711
  init(
844
712
  fileBaseKey: String, incidentId: String, apiBaseUrl: String, kind: BleIncidentLogRelayKind
@@ -1602,7 +1470,7 @@ class MentraLive: NSObject, SGCManager {
1602
1470
  // NEW: File transfer properties
1603
1471
  private var fileReadCharacteristic: CBCharacteristic?
1604
1472
  private var fileWriteCharacteristic: CBCharacteristic?
1605
- private var activeFileTransfers = [String: FileTransferSession]()
1473
+ private var activeFileTransfers = [String: MentraLiveFileTransferSession]()
1606
1474
  private var blePhotoTransfers = [String: BlePhotoTransfer]()
1607
1475
  private var bleIncidentLogRelays = [String: BleIncidentLogRelayEntry]()
1608
1476
  private var l2capFileChannel: MentraLiveL2capChannel?
@@ -2639,7 +2507,8 @@ class MentraLive: NSObject, SGCManager {
2639
2507
  // The reader thread keeps draining the stream (and returning CoC credits)
2640
2508
  // while the existing transfer state remains serialized on the main actor.
2641
2509
  DispatchQueue.main.async {
2642
- self?.processReceivedData(frame)
2510
+ guard let self, self.l2capFileChannelId == channelId else { return }
2511
+ self.processReceivedData(frame)
2643
2512
  }
2644
2513
  },
2645
2514
  onClose: { [weak self] in
@@ -2666,7 +2535,7 @@ class MentraLive: NSObject, SGCManager {
2666
2535
 
2667
2536
  // MARK: - Data Processing
2668
2537
 
2669
- // Internal so native integration tests can replay the actual BLE receive path.
2538
+ /// Internal so native integration tests can replay the actual BLE receive path.
2670
2539
  func processReceivedData(_ data: Data) {
2671
2540
  guard data.count > 0 else { return }
2672
2541
 
@@ -4161,7 +4030,7 @@ class MentraLive: NSObject, SGCManager {
4161
4030
  activeFileTransfers.removeValue(forKey: fileName)
4162
4031
  }
4163
4032
 
4164
- var session = FileTransferSession(
4033
+ var session = MentraLiveFileTransferSession(
4165
4034
  fileName: fileName, fileSize: fileSize, announcedPackets: totalPackets
4166
4035
  )
4167
4036
  session.isAnnounced = true
@@ -4171,7 +4040,7 @@ class MentraLive: NSObject, SGCManager {
4171
4040
  if var bleTransfer = blePhotoTransfers[bleImgId] {
4172
4041
  var bleSession =
4173
4042
  bleTransfer.session
4174
- ?? FileTransferSession(
4043
+ ?? MentraLiveFileTransferSession(
4175
4044
  fileName: fileName, fileSize: fileSize, announcedPackets: totalPackets
4176
4045
  )
4177
4046
  bleSession.updateAnnouncedPackets(totalPackets)
@@ -4264,8 +4133,8 @@ class MentraLive: NSObject, SGCManager {
4264
4133
 
4265
4134
  if incidentRelay.session == nil {
4266
4135
  activeFileTransfers.removeValue(forKey: packetInfo.fileName)
4267
- var session = FileTransferSession(
4268
- fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize)
4136
+ var session = MentraLiveFileTransferSession(
4137
+ fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize), flags: packetInfo.flags
4269
4138
  )
4270
4139
  session.recalculateTotalPackets(actualPackSize: Int(packetInfo.packSize))
4271
4140
  incidentRelay.session = session
@@ -4316,10 +4185,11 @@ class MentraLive: NSObject, SGCManager {
4316
4185
 
4317
4186
  // Get or create session for this transfer
4318
4187
  if photoTransfer.session == nil {
4319
- var session = FileTransferSession(
4188
+ var session = MentraLiveFileTransferSession(
4320
4189
  fileName: packetInfo.fileName,
4321
- fileSize: Int(packetInfo.fileSize)
4190
+ fileSize: Int(packetInfo.fileSize), flags: packetInfo.flags
4322
4191
  )
4192
+ session.recalculateTotalPackets(actualPackSize: Int(packetInfo.packSize))
4323
4193
  photoTransfer.session = session
4324
4194
  blePhotoTransfers[bleImgId] = photoTransfer
4325
4195
  Bridge.log(
@@ -4392,9 +4262,10 @@ class MentraLive: NSObject, SGCManager {
4392
4262
  var session = activeFileTransfers[packetInfo.fileName]
4393
4263
  if session == nil {
4394
4264
  // New file transfer
4395
- session = FileTransferSession(
4396
- fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize)
4265
+ session = MentraLiveFileTransferSession(
4266
+ fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize), flags: packetInfo.flags
4397
4267
  )
4268
+ session?.recalculateTotalPackets(actualPackSize: Int(packetInfo.packSize))
4398
4269
  activeFileTransfers[packetInfo.fileName] = session
4399
4270
 
4400
4271
  Bridge.log(
@@ -0,0 +1,137 @@
1
+ import Foundation
2
+
3
+ struct MentraLiveFileTransferSession {
4
+ let fileName: String
5
+ let fileSize: Int // NOTE: May be "fake" (inflated) due to BES firmware workaround
6
+ let dynamicPayload: Bool
7
+ var actualPackSize: Int = 0 // Actual pack size from first received packet
8
+ var totalPackets: Int
9
+ var expectedNextPacket: Int = 0
10
+ var receivedPackets: [Int: Data] = [:]
11
+ let startTime: Date
12
+ var isComplete: Bool = false
13
+ var isAnnounced: Bool = false
14
+
15
+ /// BES2700 firmware hardcodes FILE_PACK_SIZE=400 when calculating totalPack.
16
+ /// Android glasses "lie" about fileSize to make BES expect correct packet count.
17
+ private static let BES_HARDCODED_PACK_SIZE = 400
18
+
19
+ init(fileName: String, fileSize: Int, announcedPackets: Int? = nil, flags: UInt16 = 0) {
20
+ self.fileName = fileName
21
+ self.fileSize = fileSize
22
+ dynamicPayload = (flags & 0x0002) != 0
23
+ let computedPackets =
24
+ (fileSize + K900ProtocolUtils.FILE_PACK_SIZE - 1) / K900ProtocolUtils.FILE_PACK_SIZE
25
+ if let announced = announcedPackets, announced > 0 {
26
+ totalPackets = announced
27
+ isAnnounced = true
28
+ } else {
29
+ totalPackets = computedPackets
30
+ isAnnounced = false
31
+ }
32
+ startTime = Date()
33
+ }
34
+
35
+ mutating func updateAnnouncedPackets(_ announced: Int) {
36
+ guard announced > 0 else { return }
37
+ totalPackets = announced
38
+ isAnnounced = true
39
+ if expectedNextPacket >= totalPackets {
40
+ expectedNextPacket = min(expectedNextPacket, max(totalPackets - 1, 0))
41
+ }
42
+ }
43
+
44
+ /// Recalculate total packets based on actual pack size from received packet.
45
+ /// Dynamic headers carry the true byte count. Only legacy small packs use inflated sizes.
46
+ mutating func recalculateTotalPackets(actualPackSize: Int) {
47
+ guard actualPackSize > 0, actualPackSize <= K900ProtocolUtils.FILE_PACK_SIZE else { return }
48
+
49
+ self.actualPackSize = actualPackSize
50
+
51
+ // Detect BES lie: if fileSize is exact multiple of 400, glasses used the lie strategy
52
+ let isBesLie = !dynamicPayload
53
+ && (fileSize % Self.BES_HARDCODED_PACK_SIZE == 0)
54
+ && (actualPackSize < Self.BES_HARDCODED_PACK_SIZE)
55
+
56
+ let newTotalPackets: Int
57
+ if isBesLie {
58
+ // BES lie detected: totalPackets = fileSize / 400
59
+ newTotalPackets = fileSize / Self.BES_HARDCODED_PACK_SIZE
60
+ Bridge.log(
61
+ "📦 BES Lie detected! fakeFileSize=\(fileSize), totalPackets=\(newTotalPackets), actualPackSize=\(actualPackSize)"
62
+ )
63
+ } else {
64
+ // Normal case: calculate based on actual pack size
65
+ newTotalPackets = (fileSize + actualPackSize - 1) / actualPackSize
66
+ }
67
+
68
+ if newTotalPackets != totalPackets {
69
+ Bridge.log(
70
+ "📦 Recalculating totalPackets: \(totalPackets) -> \(newTotalPackets) (packSize=\(actualPackSize), fileSize=\(fileSize))"
71
+ )
72
+ totalPackets = newTotalPackets
73
+ }
74
+ }
75
+
76
+ mutating func addPacket(_ index: Int, data: Data) -> Bool {
77
+ guard index >= 0 else { return false }
78
+
79
+ // On first packet, recalculate total packets only when we do not already
80
+ // have an authoritative pack size from protocol metadata.
81
+ if receivedPackets.isEmpty && actualPackSize == 0 && !data.isEmpty {
82
+ recalculateTotalPackets(actualPackSize: data.count)
83
+ }
84
+
85
+ guard index < totalPackets else { return false }
86
+ if dynamicPayload {
87
+ let expectedSize = min(actualPackSize, fileSize - index * actualPackSize)
88
+ guard expectedSize > 0, data.count == expectedSize else { return false }
89
+ }
90
+
91
+ guard receivedPackets[index] == nil else {
92
+ return false
93
+ }
94
+
95
+ receivedPackets[index] = data
96
+
97
+ while receivedPackets[expectedNextPacket] != nil, expectedNextPacket < totalPackets {
98
+ expectedNextPacket += 1
99
+ }
100
+
101
+ isComplete = (receivedPackets.count == totalPackets)
102
+ return true
103
+ }
104
+
105
+ func isFinalPacket(_ index: Int) -> Bool {
106
+ index == totalPackets - 1
107
+ }
108
+
109
+ func missingPacketIndices() -> [Int] {
110
+ guard totalPackets > receivedPackets.count else { return [] }
111
+ return (0 ..< totalPackets).compactMap { receivedPackets[$0] == nil ? $0 : nil }
112
+ }
113
+
114
+ /// Assemble file from received packets.
115
+ /// NOTE: Calculates actual file size from received data, NOT from header fileSize,
116
+ /// because fileSize may be "fake" (inflated) due to BES firmware workaround.
117
+ func assembleFile() -> Data? {
118
+ guard isComplete else { return nil }
119
+
120
+ // Calculate actual file size by summing all received packet sizes
121
+ let actualFileSize = receivedPackets.values.reduce(0) { $0 + $1.count }
122
+
123
+ Bridge.log(
124
+ "📦 Assembling file: headerFileSize=\(fileSize), actualFileSize=\(actualFileSize), totalPackets=\(totalPackets)"
125
+ )
126
+
127
+ var fileData = Data(capacity: actualFileSize)
128
+
129
+ for i in 0 ..< totalPackets {
130
+ if let packet = receivedPackets[i] {
131
+ fileData.append(packet)
132
+ }
133
+ }
134
+
135
+ return fileData
136
+ }
137
+ }
@@ -0,0 +1,47 @@
1
+ import Foundation
2
+ @testable import MentraBluetoothSDK
3
+ import XCTest
4
+
5
+ final class MentraLiveFileTransferSessionTests: XCTestCase {
6
+ func testDynamicPayloadUsesTrueSizeEvenWhenDivisibleBy400() {
7
+ for pack in [244, 474, 800] {
8
+ var session = MentraLiveFileTransferSession(fileName: "photo", fileSize: 80000, flags: 2)
9
+ session.recalculateTotalPackets(actualPackSize: pack)
10
+ XCTAssertEqual(session.totalPackets, (80000 + pack - 1) / pack)
11
+ for index in 0 ..< session.totalPackets {
12
+ XCTAssertTrue(session.addPacket(index, data: Data(repeating: 7, count: min(pack, 80000 - index * pack))))
13
+ }
14
+ XCTAssertTrue(session.isComplete)
15
+ XCTAssertEqual(session.assembleFile()?.count, 80000)
16
+ }
17
+ }
18
+
19
+ func testLegacyInflatedSizeAndLargeUnflaggedPayloads() {
20
+ // Old senders inflate the header length for small packs, including
21
+ // the short final pack. Flag 1 only enables batched ACKs.
22
+ for (pack, headerSize, actualSize, packets) in [(221, 80000, 43982, 200), (400, 80000, 79777, 200), (800, 80000, 80000, 100)] {
23
+ var session = MentraLiveFileTransferSession(fileName: "photo", fileSize: headerSize, flags: 1)
24
+ session.recalculateTotalPackets(actualPackSize: pack)
25
+ XCTAssertEqual(session.totalPackets, packets)
26
+ let source = Data((0 ..< actualSize).map { UInt8($0 % 251) })
27
+ for index in (0 ..< packets).reversed() {
28
+ let start = index * pack
29
+ XCTAssertTrue(session.addPacket(index, data: source.subdata(in: start ..< min(start + pack, actualSize))))
30
+ }
31
+ XCTAssertTrue(session.isComplete)
32
+ XCTAssertEqual(session.assembleFile(), source)
33
+ }
34
+ }
35
+
36
+ func testMalformedOrDuplicatePacketsCannotCompleteFile() {
37
+ var session = MentraLiveFileTransferSession(fileName: "photo", fileSize: 1000, flags: 2)
38
+ session.recalculateTotalPackets(actualPackSize: 800)
39
+ XCTAssertFalse(session.addPacket(0, data: Data(count: 799)))
40
+ XCTAssertFalse(session.addPacket(2, data: Data(count: 200)))
41
+ XCTAssertTrue(session.addPacket(1, data: Data(count: 200)))
42
+ XCTAssertFalse(session.isComplete)
43
+ XCTAssertFalse(session.addPacket(1, data: Data(count: 200)))
44
+ XCTAssertTrue(session.addPacket(0, data: Data(count: 800)))
45
+ XCTAssertEqual(session.assembleFile()?.count, 1000)
46
+ }
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.3.0-dev.513",
3
+ "version": "3.3.0-dev.514",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -114,7 +114,7 @@
114
114
  "registry": "https://registry.npmjs.org/"
115
115
  },
116
116
  "dependencies": {
117
- "@mentra/cloud-protocol": "3.3.0-dev.513"
117
+ "@mentra/cloud-protocol": "3.3.0-dev.514"
118
118
  },
119
119
  "devDependencies": {
120
120
  "@types/node": "^25.9.3",
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
12
12
  export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "3.3.0",
15
- "releaseIdentity": "3.3.0-dev.513",
16
- "releaseSetId": "mentra-3.3.0-dev.513",
17
- "sourceCommit": "abeb5904b244689cdfee105ac4eed26629df40ac",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.513.json",
19
- "otaManifestSha256": "bc98768e1baee50bdb955ef230822f4070e0c20bc910d1960a1b2e4307a0f077"
15
+ "releaseIdentity": "3.3.0-dev.514",
16
+ "releaseSetId": "mentra-3.3.0-dev.514",
17
+ "sourceCommit": "c08afa1e430e63dffd758478435e8d59940d36c7",
18
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.514.json",
19
+ "otaManifestSha256": "24a37a482d08ef1a11ff5c96280c641150601b255e336f7723dbf88cb598337e"
20
20
  })