@glassly/bluetooth-sdk 0.1.0-dev.61 → 0.1.0-dev.89

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 (48) hide show
  1. package/README.md +4 -4
  2. package/android/src/main/assets/G2Firmware/README.md +3 -3
  3. package/android/src/main/assets/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
  4. package/android/src/main/assets/G2Firmware/g2-stock-2.2.9.22.evenota.bin +0 -0
  5. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +0 -4
  6. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +0 -1
  7. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +30 -86
  8. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +1 -2
  9. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +3 -3
  10. package/android/src/main/java/com/glassly/bluetoothsdk/controllers/R1.kt +171 -51
  11. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +23 -3
  12. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +18 -6
  13. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
  14. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +8 -8
  15. package/build/BluetoothSdk.types.d.ts +37 -1
  16. package/build/BluetoothSdk.types.d.ts.map +1 -1
  17. package/build/BluetoothSdk.types.js.map +1 -1
  18. package/build/_private/BluetoothSdkModule.d.ts +0 -1
  19. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  20. package/build/_private/BluetoothSdkModule.js.map +1 -1
  21. package/build/generated/releaseMetadata.js +3 -3
  22. package/build/generated/releaseMetadata.js.map +1 -1
  23. package/build/index.d.ts.map +1 -1
  24. package/build/index.js +0 -1
  25. package/build/index.js.map +1 -1
  26. package/ios/BluetoothSdkModule.swift +0 -6
  27. package/ios/Source/BluetoothSDK.swift +0 -4
  28. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  29. package/ios/Source/DeviceManager.swift +35 -88
  30. package/ios/Source/DeviceStore.swift +16 -2
  31. package/ios/Source/GeneratedReleaseMetadata.swift +3 -3
  32. package/ios/Source/Resources/G2Firmware/README.md +3 -3
  33. package/ios/Source/Resources/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
  34. package/ios/Source/Resources/G2Firmware/g2-stock-2.2.9.22.evenota.bin +0 -0
  35. package/ios/Source/controllers/R1.swift +145 -49
  36. package/ios/Source/sgcs/G2.swift +311 -54
  37. package/ios/Source/sgcs/G2CfwRenderer.swift +24 -8
  38. package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
  39. package/ios/Source/sgcs/G2Flasher.swift +8 -8
  40. package/ios/Source/sgcs/SGCManager.swift +13 -0
  41. package/ios/Source/utils/G2NotifyFile.swift +116 -0
  42. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +2 -2
  43. package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +91 -0
  44. package/package.json +1 -1
  45. package/src/BluetoothSdk.types.ts +37 -1
  46. package/src/_private/BluetoothSdkModule.ts +0 -1
  47. package/src/generated/releaseMetadata.ts +3 -3
  48. package/src/index.ts +0 -1
@@ -44,6 +44,14 @@ private enum G2BLE {
44
44
  static let CHAR_WRITE = CBUUID(string: "00002760-08C2-11E1-9073-0E8AC72E5401")
45
45
  static let CHAR_NOTIFY = CBUUID(string: "00002760-08C2-11E1-9073-0E8AC72E5402")
46
46
  static let AUDIO_NOTIFY = CBUUID(string: "00002760-08C2-11E1-9073-0E8AC72E6402")
47
+ // File-transfer characteristics (notification JSON / whitelist pushes ride
48
+ // the 0xC4/0xC5 file services here, not the main 5401 protocol char)
49
+ static let FILE_WRITE = CBUUID(string: "00002760-08C2-11E1-9073-0E8AC72E7401")
50
+ static let FILE_NOTIFY = CBUUID(string: "00002760-08C2-11E1-9073-0E8AC72E7402")
51
+
52
+ // File-transfer service IDs (same AA-21 envelope as every other service)
53
+ static let FILE_SERVICE_CMD: UInt8 = 0xC4 // FILE_CHECK header, START (0x01), END (0x02)
54
+ static let FILE_SERVICE_DATA: UInt8 = 0xC5 // raw file bytes
47
55
 
48
56
  /// We discover services by scanning for these characteristics
49
57
  /// The service UUID that contains these chars
@@ -136,6 +144,14 @@ private enum G2SettingCommandId: Int32 {
136
144
  case deviceRespondToApp = 4 // Glasses responds to app
137
145
  }
138
146
 
147
+ /// eNotificationCommandId from notification.proto (service 0x04)
148
+ private enum NotificationCmd: Int32 {
149
+ case ctrl = 1 // NOTIFICATION_CTRL — NotificationControl in field 3
150
+ case ios = 2 // NOTIFICATION_IOS — glasses report an ANCS source app in field 4
151
+ case whitelistCtrl = 3 // NOTIFICATION_WHITELIST_CTRL — NotificationWhitelistCtrl in field 6
152
+ case commRsp = 161 // NOTIFICATION_COMM_RSP
153
+ }
154
+
139
155
  /// DevCfgCommandId from dev_config_protocol.proto
140
156
  private enum DevCfgCommandId: Int32 {
141
157
  case authentication = 4
@@ -1608,7 +1624,9 @@ private final class ImgAckBox {
1608
1624
  self.session = nil
1609
1625
  self.fragment = nil
1610
1626
  cont = nil
1611
- if success { lastSuccessMs = Int64(Date().timeIntervalSince1970 * 1000) }
1627
+ if success {
1628
+ lastSuccessMs = Int64(Date().timeIntervalSince1970 * 1000)
1629
+ }
1612
1630
  lock.unlock()
1613
1631
  c.resume(returning: success)
1614
1632
  return true
@@ -1699,7 +1717,7 @@ final class G2: NSObject, SGCManager {
1699
1717
  private var cfwSceneActive = false
1700
1718
  /// Rate limit on resurrecting the page for a blank scene; a non-blank one rebuilds immediately.
1701
1719
  private var cfwLastIdleResurrectAt: Date?
1702
- private static let cfwIdleResurrectMinIntervalMs: Double = 5_000
1720
+ private static let cfwIdleResurrectMinIntervalMs: Double = 5000
1703
1721
  private static let cfwSceneContainerID: Int32 = 10
1704
1722
  private let cfwRenderer = G2CfwRenderer()
1705
1723
  /// Deltas composite onto a shadow seeded by a mode-6 keyframe, so payloads drain in order.
@@ -1762,13 +1780,20 @@ final class G2: NSObject, SGCManager {
1762
1780
 
1763
1781
  /// Mode-9 rect copies have no capability token; they shipped with
1764
1782
  /// protocol revision 15 alongside the texture/font modes.
1765
- private var supportsCfwRectCopy: Bool { cfwProtocolVersion >= 15 }
1783
+ private var supportsCfwRectCopy: Bool {
1784
+ cfwProtocolVersion >= 15
1785
+ }
1786
+
1766
1787
  /// Mode-15 builtin-font string draws (`font15` capability token).
1767
- private var supportsCfwBuiltinText: Bool { cfwFeatures.contains("font15") }
1788
+ private var supportsCfwBuiltinText: Bool {
1789
+ cfwFeatures.contains("font15")
1790
+ }
1791
+
1768
1792
  /// Mode-12 uploads + mode-14 cached-glyph runs (mono text as draws).
1769
1793
  private var supportsCfwTextureGlyphs: Bool {
1770
1794
  cfwFeatures.contains("texcache12") && cfwFeatures.contains("texstr14")
1771
1795
  }
1796
+
1772
1797
  /// Mode-12 uploads + mode-13 cached-image draws (repeated icons as draws).
1773
1798
  private var supportsCfwTextureImages: Bool {
1774
1799
  cfwFeatures.contains("texcache12") && cfwFeatures.contains("teximg13")
@@ -1787,6 +1812,10 @@ final class G2: NSObject, SGCManager {
1787
1812
  private var rightNotifyChar: CBCharacteristic?
1788
1813
  private var rightAudioChar: CBCharacteristic?
1789
1814
  private var leftAudioChar: CBCharacteristic?
1815
+ private var leftFileWriteChar: CBCharacteristic?
1816
+ private var rightFileWriteChar: CBCharacteristic?
1817
+ private var leftFileNotifyChar: CBCharacteristic?
1818
+ private var rightFileNotifyChar: CBCharacteristic?
1790
1819
  private var leftInitialized: Bool = false
1791
1820
  private var rightInitialized: Bool = false
1792
1821
  private var leftAuthenticated: Bool = false
@@ -1813,6 +1842,15 @@ final class G2: NSObject, SGCManager {
1813
1842
  // and is dropped entirely — see the SysEvent handler.
1814
1843
  private let PHANTOM_EXIT_ACK_WINDOW_MS: Int64 = 2500
1815
1844
 
1845
+ /// Shared connect options. RequiresANCS raises the iOS "Share System
1846
+ /// Notifications" consent so the glasses' on-firmware ANCS client can read
1847
+ /// the phone's notification stream (the phone app itself never can).
1848
+ private static let peripheralConnectOptions: [String: Any] = [
1849
+ CBConnectPeripheralOptionNotifyOnConnectionKey: true,
1850
+ CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
1851
+ CBConnectPeripheralOptionRequiresANCS: true,
1852
+ ]
1853
+
1816
1854
  /// Device search
1817
1855
  var DEVICE_SEARCH_ID = "NOT_SET"
1818
1856
  /// map device names to serial numbers:
@@ -2415,6 +2453,12 @@ final class G2: NSObject, SGCManager {
2415
2453
  let calendarEvents =
2416
2454
  DeviceStore.shared.get("bluetooth", "calendar_events") as? [[String: Any]] ?? []
2417
2455
  sendCalendarEvents(calendarEvents)
2456
+
2457
+ // push ANCS notification config (display options + app filter) and
2458
+ // surface the current ANCS authorization state to the UI
2459
+ publishAncsAuthorization()
2460
+ sendNotificationCtrl()
2461
+ sendNotificationFilter()
2418
2462
  }
2419
2463
 
2420
2464
  // MARK: - Heartbeats
@@ -2521,6 +2565,106 @@ final class G2: NSObject, SGCManager {
2521
2565
  setDashboardMenu(menuItems)
2522
2566
  }
2523
2567
 
2568
+ // MARK: - Notifications (on-glass ANCS display options + app filter)
2569
+
2570
+ /// Push NotificationControl (master toggle, auto-display popup, on-screen
2571
+ /// duration, avoid-disturb) from the DeviceStore `notification_ctrl` dict.
2572
+ /// Full state is retransmitted on every change — that matches the stock app
2573
+ /// and the glasses echo it back as the ack.
2574
+ func sendNotificationCtrl() {
2575
+ let ctrl = DeviceStore.shared.get("bluetooth", "notification_ctrl") as? [String: Any] ?? [:]
2576
+ let enabled = ctrl["enabled"] as? Bool ?? true
2577
+ let autoDisplay = ctrl["auto_display"] as? Bool ?? true
2578
+ let dispTime = ctrl["disp_time"] as? Int ?? 5
2579
+ let avoidDisturb = ctrl["avoid_disturb"] as? Bool ?? true
2580
+
2581
+ var ctrlW = ProtobufWriter()
2582
+ ctrlW.writeInt32Field(1, enabled ? 1 : 0) // notifEnable
2583
+ ctrlW.writeInt32Field(2, autoDisplay ? 1 : 0) // autoDispEnable
2584
+ ctrlW.writeInt32Field(3, Int32(dispTime)) // dispTime (0/5/7/10 s)
2585
+ ctrlW.writeInt32Field(5, avoidDisturb ? 1 : 0) // avoidDisturbEnable
2586
+
2587
+ var pkg = ProtobufWriter()
2588
+ pkg.writeInt32Field(1, NotificationCmd.ctrl.rawValue)
2589
+ pkg.writeInt32Field(2, sendManager.nextMagicRandom())
2590
+ pkg.writeMessageField(3, ctrlW.data)
2591
+ sendNotificationCommand(pkg.data)
2592
+ Bridge.log(
2593
+ "G2: Sent notification ctrl enabled=\(enabled) autoDisplay=\(autoDisplay) dispTime=\(dispTime) avoidDisturb=\(avoidDisturb)"
2594
+ )
2595
+ }
2596
+
2597
+ /// Push the ANCS app allowlist from the DeviceStore `notification_filter`
2598
+ /// dict ({enabled, apps: [{id, name}]}). The list travels as the
2599
+ /// `user/notify_whitelist.json` file (the protobuf whitelist message only
2600
+ /// carries the enable toggle); the WHITELIST_CTRL toggle goes after the
2601
+ /// file so the firmware reloads with the new list in place.
2602
+ func sendNotificationFilter() {
2603
+ let filter = DeviceStore.shared.get("bluetooth", "notification_filter") as? [String: Any] ?? [:]
2604
+ let enabled = filter["enabled"] as? Bool ?? false
2605
+ let apps = filter["apps"] as? [[String: Any]] ?? []
2606
+ Task { [weak self] in
2607
+ await self?.pushNotificationFilter(enabled: enabled, apps: apps)
2608
+ }
2609
+ }
2610
+
2611
+ private func pushNotificationFilter(enabled: Bool, apps: [[String: Any]]) async {
2612
+ let entries = apps.compactMap { app -> (id: String, name: String)? in
2613
+ guard let id = app["id"] as? String, !id.isEmpty else { return nil }
2614
+ return (id: id, name: app["name"] as? String ?? id)
2615
+ }
2616
+ // The file is written even when filtering is off so a later enable
2617
+ // toggles against the user's current list, not a stale one.
2618
+ let json = G2NotifyFile.whitelistJson(filterEnabled: enabled, apps: entries)
2619
+ await sendNotifyFile(named: G2NotifyFile.whitelistFilename, content: json)
2620
+
2621
+ var wlW = ProtobufWriter()
2622
+ wlW.writeInt32Field(1, enabled ? 0 : 1) // whitelistDisable (disabled = fail-open)
2623
+ var pkg = ProtobufWriter()
2624
+ pkg.writeInt32Field(1, NotificationCmd.whitelistCtrl.rawValue)
2625
+ pkg.writeInt32Field(2, sendManager.nextMagicRandom())
2626
+ pkg.writeMessageField(6, wlW.data)
2627
+ sendNotificationCommand(pkg.data)
2628
+ Bridge.log("G2: Sent notification filter enabled=\(enabled) apps=\(entries.count)")
2629
+ }
2630
+
2631
+ /// File-transfer push (services 0xC4/0xC5 on the dedicated file
2632
+ /// characteristic): FILE_CHECK header → START → DATA → END, per arm. The
2633
+ /// glasses cache by checksum but still expect the data packets either way,
2634
+ /// so no branching on the CACHE_HIT/MISS response is needed.
2635
+ private func sendNotifyFile(named filename: String, content: Data) async {
2636
+ guard !firmwareFlashLifecycleActive, !firmwareFlasher.isActive else { return }
2637
+ let header = G2NotifyFile.fileCheckHeader(filename: filename, content: content)
2638
+ var pushedSides: [String] = []
2639
+ for right in [true, false] {
2640
+ guard let peripheral = right ? rightPeripheral : leftPeripheral,
2641
+ let char = right ? rightFileWriteChar : leftFileWriteChar
2642
+ else { continue }
2643
+ let writeType: CBCharacteristicWriteType =
2644
+ char.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse
2645
+
2646
+ func write(_ packets: [Data]) async {
2647
+ for packet in packets {
2648
+ peripheral.writeValue(packet, for: char, type: writeType)
2649
+ try? await Task.sleep(nanoseconds: 8_000_000)
2650
+ }
2651
+ }
2652
+
2653
+ await write(sendManager.buildPackets(serviceId: G2BLE.FILE_SERVICE_CMD, payload: header))
2654
+ try? await Task.sleep(nanoseconds: 150_000_000)
2655
+ await write(sendManager.buildPackets(serviceId: G2BLE.FILE_SERVICE_CMD, payload: Data([0x01]))) // START
2656
+ try? await Task.sleep(nanoseconds: 50_000_000)
2657
+ await write(sendManager.buildPackets(serviceId: G2BLE.FILE_SERVICE_DATA, payload: content))
2658
+ try? await Task.sleep(nanoseconds: 50_000_000)
2659
+ await write(sendManager.buildPackets(serviceId: G2BLE.FILE_SERVICE_CMD, payload: Data([0x02]))) // END
2660
+ try? await Task.sleep(nanoseconds: 50_000_000)
2661
+ pushedSides.append(right ? "R" : "L")
2662
+ }
2663
+ Bridge.log(
2664
+ "G2: Pushed \(filename) (\(content.count) bytes) to [\(pushedSides.joined(separator: ","))]"
2665
+ )
2666
+ }
2667
+
2524
2668
  // MARK: - SGCManager: Display Control
2525
2669
 
2526
2670
  func sendTextWall(_ text: String) async {
@@ -2855,7 +2999,8 @@ final class G2: NSObject, SGCManager {
2855
2999
  // hatch may have been flipped off mid-session — without this
2856
3000
  // check the lease renews forever while frames go stock.
2857
3001
  if self.cfwSceneActive, self.pageCreated, self.dashboardShowing == 0,
2858
- self.supportsCfwSceneRendering {
3002
+ self.supportsCfwSceneRendering
3003
+ {
2859
3004
  self.sendFaceclawFbControl(acquire: true)
2860
3005
  } else {
2861
3006
  self.stopFbLease(sendRelease: true)
@@ -2868,7 +3013,9 @@ final class G2: NSObject, SGCManager {
2868
3013
  private func stopFbLease(sendRelease: Bool) {
2869
3014
  fbLeaseTask?.cancel()
2870
3015
  fbLeaseTask = nil
2871
- if sendRelease { sendFaceclawFbControl(acquire: false) }
3016
+ if sendRelease {
3017
+ sendFaceclawFbControl(acquire: false)
3018
+ }
2872
3019
  // Lease end frees the on-glass texture cache; the model must re-learn.
2873
3020
  cfwCacheGeneration += 1
2874
3021
  }
@@ -2879,7 +3026,11 @@ final class G2: NSObject, SGCManager {
2879
3026
  /// expire) restores stock behavior and launches any deferred dashboard.
2880
3027
  func wakeLeaseEnabled(_ enabled: Bool) {
2881
3028
  wakeLeaseWanted = enabled
2882
- if enabled { startWakeLease() } else { stopWakeLease(sendRelease: true) }
3029
+ if enabled {
3030
+ startWakeLease()
3031
+ } else {
3032
+ stopWakeLease(sendRelease: true)
3033
+ }
2883
3034
  }
2884
3035
 
2885
3036
  /// Like the fb lease, the wake lease is per lens and must reach both arms.
@@ -2908,7 +3059,9 @@ final class G2: NSObject, SGCManager {
2908
3059
  private func stopWakeLease(sendRelease: Bool) {
2909
3060
  wakeLeaseTask?.cancel()
2910
3061
  wakeLeaseTask = nil
2911
- if sendRelease { sendFaceclawWakeControl(acquire: false) }
3062
+ if sendRelease {
3063
+ sendFaceclawWakeControl(acquire: false)
3064
+ }
2912
3065
  }
2913
3066
 
2914
3067
  /// The firmware lease is volatile and dies with the link, while
@@ -2951,11 +3104,28 @@ final class G2: NSObject, SGCManager {
2951
3104
  let textDraws = useBuiltinText ? G2CfwRenderer.builtinTextDraws(frame, boxes: boxes) : []
2952
3105
  // Text pixels are never in the raster: force old + new bounds dirty so stale glyphs erase.
2953
3106
  var forced: [G2CfwRenderer.CanvasRect] = []
3107
+ var marginBlank: G2CfwRenderer.LensBoxes?
2954
3108
  if textDraws != cfwLastTextDraws {
2955
3109
  forced = G2CfwRenderer.builtinTextBounds(
2956
3110
  cfwLastTextDraws + textDraws, boxes: boxes,
2957
3111
  canvasWidth: Int(boxes.width), canvasHeight: Int(boxes.height)
2958
3112
  )
3113
+ // Mode 15 doesn't clip at the window's right edge: a line the
3114
+ // phone's metrics under-measured can spill into the outer panel
3115
+ // margin, where no in-window rect or full frame ever repaints.
3116
+ // Blank the old window's right margin whenever draws change.
3117
+ // (Builtin frames pin depth, so the old window is lens-equal.)
3118
+ if !cfwLastTextDraws.isEmpty, let last = cfwLastBoxes, last.left == last.right {
3119
+ let edgeX = Int32(last.left.x) + last.width
3120
+ let bandW = Int32(G2CfwRenderer.frameWidth) - edgeX
3121
+ if bandW >= 4 {
3122
+ marginBlank = G2CfwRenderer.LensBoxes(
3123
+ left: CGPoint(x: CGFloat(edgeX), y: last.left.y),
3124
+ right: CGPoint(x: CGFloat(edgeX), y: last.right.y),
3125
+ width: bandW, height: last.height
3126
+ )
3127
+ }
3128
+ }
2959
3129
  }
2960
3130
  let fids = (0 ..< G2CfwRenderer.requiredFidCount).map { _ in nextCfwFrameId() }
2961
3131
  // Copies would smear firmware-drawn glyphs the raster knows nothing about.
@@ -2966,6 +3136,7 @@ final class G2: NSObject, SGCManager {
2966
3136
  forcedRects: forced, fids: fids, textDraws: textDraws,
2967
3137
  allowRectCopy: allowCopy,
2968
3138
  blankBoxes: movedBoxes ? cfwLastBoxes : nil,
3139
+ marginBlankBoxes: marginBlank,
2969
3140
  omitBuiltinText: useBuiltinText,
2970
3141
  textureGlyphsEnabled: useTextureGlyphs,
2971
3142
  textureImagesEnabled: supportsCfwTextureImages && pinDepth,
@@ -3030,7 +3201,9 @@ final class G2: NSObject, SGCManager {
3030
3201
  /// whether they need a page rebuild.
3031
3202
  @discardableResult
3032
3203
  private func installCfwCarrierIfNeeded() -> Bool {
3033
- if textContainers.isEmpty && hasCfwCarrier { return true }
3204
+ if textContainers.isEmpty && hasCfwCarrier {
3205
+ return true
3206
+ }
3034
3207
  textContainers.removeAll()
3035
3208
  sceneTextByElement.removeAll()
3036
3209
  sceneImageByElement.removeAll()
@@ -3093,7 +3266,9 @@ final class G2: NSObject, SGCManager {
3093
3266
  // its normal paths.
3094
3267
  guard cfwClassified, cfwCapabilityString != nil else { return }
3095
3268
  if enabled {
3096
- if installCfwCarrierIfNeeded() { return } // already the carrier page
3269
+ if installCfwCarrierIfNeeded() {
3270
+ return
3271
+ } // already the carrier page
3097
3272
  Bridge.log("G2: cfw_scene_renderer ON — swapping stock containers for the CFW carrier")
3098
3273
  await requestPageRebuild()
3099
3274
  } else {
@@ -3381,7 +3556,9 @@ final class G2: NSObject, SGCManager {
3381
3556
  // the idle state is the same page everywhere.
3382
3557
  let carrierWasConfigured = installCfwCarrierIfNeeded()
3383
3558
  cfwClear()
3384
- if !carrierWasConfigured || !pageCreated { signalDisplayDirty() }
3559
+ if !carrierWasConfigured || !pageCreated {
3560
+ signalDisplayDirty()
3561
+ }
3385
3562
  if !carrierWasConfigured, pageCreated {
3386
3563
  // The live on-glass page still holds the stock containers the
3387
3564
  // carrier just replaced locally (legacy text drawn before CFW
@@ -3728,7 +3905,9 @@ final class G2: NSObject, SGCManager {
3728
3905
  if ok {
3729
3906
  Bridge.log("G2: page-down probe acked — phantom exit, page alive (no rebuild)")
3730
3907
  pageCreated = true
3731
- if !cfwPendingPayloads.isEmpty { cfwPendingPayloads.removeFirst() }
3908
+ if !cfwPendingPayloads.isEmpty {
3909
+ cfwPendingPayloads.removeFirst()
3910
+ }
3732
3911
  signalDisplayDirty() // drain the rest normally
3733
3912
  return
3734
3913
  }
@@ -3778,7 +3957,9 @@ final class G2: NSObject, SGCManager {
3778
3957
  // loop (rebuild→systemExit→rebuild every debounce tick).
3779
3958
  if pageCreated, supportsCfwSceneRendering, hasCfwCarrier {
3780
3959
  cfwResetShadow()
3781
- if let frame = cfwLastFrame { _ = await enqueueCfwFrame(frame) }
3960
+ if let frame = cfwLastFrame {
3961
+ _ = await enqueueCfwFrame(frame)
3962
+ }
3782
3963
  }
3783
3964
  break
3784
3965
  }
@@ -4721,6 +4902,10 @@ final class G2: NSObject, SGCManager {
4721
4902
  rightNotifyChar = nil
4722
4903
  rightAudioChar = nil
4723
4904
  leftAudioChar = nil
4905
+ leftFileWriteChar = nil
4906
+ rightFileWriteChar = nil
4907
+ leftFileNotifyChar = nil
4908
+ rightFileNotifyChar = nil
4724
4909
  DEVICE_SEARCH_ID = "NOT_SET"
4725
4910
  centralManager?.delegate = nil
4726
4911
  }
@@ -4859,10 +5044,12 @@ final class G2: NSObject, SGCManager {
4859
5044
  }
4860
5045
 
4861
5046
  func dbg2() {
4862
-
4863
5047
  Task {
5048
+ dashboardShowing = 1
5049
+ dashboardOpening = true
4864
5050
  let msg = EvenHubProto.shutdownMessage()
4865
5051
  sendEvenHubCommand(msg)
5052
+ pageCreated = false
4866
5053
  try? await Task.sleep(nanoseconds: 300_000_000) // 300ms to settle
4867
5054
  await showNotificationsPanel()
4868
5055
  }
@@ -5308,6 +5495,10 @@ final class G2: NSObject, SGCManager {
5308
5495
  rightNotifyChar = nil
5309
5496
  leftAudioChar = nil
5310
5497
  rightAudioChar = nil
5498
+ leftFileWriteChar = nil
5499
+ rightFileWriteChar = nil
5500
+ leftFileNotifyChar = nil
5501
+ rightFileNotifyChar = nil
5311
5502
  authStarted = false
5312
5503
  leftAuthenticated = false
5313
5504
  rightAuthenticated = false
@@ -5383,10 +5574,7 @@ final class G2: NSObject, SGCManager {
5383
5574
  device.discoverServices([G2BLE.SERVICE_UUID])
5384
5575
  centralManager!.connect(
5385
5576
  leftPeripheral!,
5386
- options: [
5387
- CBConnectPeripheralOptionNotifyOnConnectionKey: true,
5388
- CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
5389
- ]
5577
+ options: G2.peripheralConnectOptions
5390
5578
  )
5391
5579
  } else if name.contains("_R_") && serialNumber.contains(DEVICE_SEARCH_ID) {
5392
5580
  rightPeripheral = device
@@ -5394,10 +5582,7 @@ final class G2: NSObject, SGCManager {
5394
5582
  device.discoverServices([G2BLE.SERVICE_UUID])
5395
5583
  centralManager!.connect(
5396
5584
  rightPeripheral!,
5397
- options: [
5398
- CBConnectPeripheralOptionNotifyOnConnectionKey: true,
5399
- CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
5400
- ]
5585
+ options: G2.peripheralConnectOptions
5401
5586
  )
5402
5587
  }
5403
5588
  // we can't emit the serial number here unfortunately:
@@ -5461,8 +5646,8 @@ final class G2: NSObject, SGCManager {
5461
5646
  rightPeripheral = right
5462
5647
  left.delegate = self
5463
5648
  right.delegate = self
5464
- centralManager?.connect(left, options: nil)
5465
- centralManager?.connect(right, options: nil)
5649
+ centralManager?.connect(left, options: G2.peripheralConnectOptions)
5650
+ centralManager?.connect(right, options: G2.peripheralConnectOptions)
5466
5651
  return true
5467
5652
  }
5468
5653
 
@@ -5602,31 +5787,39 @@ final class G2: NSObject, SGCManager {
5602
5787
  // path Android's NotificationListenerService feeds. iOS can't read
5603
5788
  // other apps' notifications, but the glasses CAN (ANCS) and report the
5604
5789
  // source app here; title/content are empty because this packet only
5605
- // carries app identity.
5606
- // commented out as this is borderline useless without content
5607
- // if cmd == 2, let iosData = fields[4] as? Data {
5608
- // var ios = ProtobufReader(iosData)
5609
- // let iosFields = ios.parseFields()
5610
- // let appID = (iosFields[1] as? Data).flatMap { String(data: $0, encoding: .utf8) }?
5611
- // .trimmingCharacters(in: CharacterSet(charactersIn: "\0")) ?? ""
5612
- // let name = (iosFields[2] as? Data).flatMap { String(data: $0, encoding: .utf8) }?
5613
- // .trimmingCharacters(in: CharacterSet(charactersIn: "\0")) ?? ""
5614
- // if !appID.isEmpty {
5615
- // let now = Int64(Date().timeIntervalSince1970 * 1000)
5616
- // Bridge.sendTypedMessage(
5617
- // "phone_notification",
5618
- // body: [
5619
- // "notificationId": "ancs-\(appID)-\(now)",
5620
- // "app": name.isEmpty ? appID : name,
5621
- // "title": "",
5622
- // "content": "",
5623
- // "priority": "0",
5624
- // "timestamp": now,
5625
- // "packageName": appID,
5626
- // ]
5627
- // )
5628
- // }
5629
- // }
5790
+ // carries app identity. The dedicated notification_app_seen event feeds
5791
+ // the engine's seen-apps store, which backs the ANCS filter UI.
5792
+ if cmd == NotificationCmd.ios.rawValue, let iosData = fields[4] as? Data {
5793
+ var ios = ProtobufReader(iosData)
5794
+ let iosFields = ios.parseFields()
5795
+ let appID = (iosFields[1] as? Data).flatMap { String(data: $0, encoding: .utf8) }?
5796
+ .trimmingCharacters(in: CharacterSet(charactersIn: "\0")) ?? ""
5797
+ let name = (iosFields[2] as? Data).flatMap { String(data: $0, encoding: .utf8) }?
5798
+ .trimmingCharacters(in: CharacterSet(charactersIn: "\0")) ?? ""
5799
+ if !appID.isEmpty {
5800
+ let now = Int64(Date().timeIntervalSince1970 * 1000)
5801
+ Bridge.sendTypedMessage(
5802
+ "notification_app_seen",
5803
+ body: [
5804
+ "appId": appID,
5805
+ "displayName": name,
5806
+ "timestamp": now,
5807
+ ]
5808
+ )
5809
+ Bridge.sendTypedMessage(
5810
+ "phone_notification",
5811
+ body: [
5812
+ "notificationId": "ancs-\(appID)-\(now)",
5813
+ "app": name.isEmpty ? appID : name,
5814
+ "title": "",
5815
+ "content": "",
5816
+ "priority": "0",
5817
+ "timestamp": now,
5818
+ "packageName": appID,
5819
+ ]
5820
+ )
5821
+ }
5822
+ }
5630
5823
  }
5631
5824
 
5632
5825
  /// EvenAI service (0x07). Logs the decoded EvenAIDataPackage so we can read the
@@ -6145,9 +6338,10 @@ final class G2: NSObject, SGCManager {
6145
6338
  // )
6146
6339
 
6147
6340
  if connStatus == 22 {
6148
- Bridge.log("G2: Ring disconnected")
6149
- DeviceStore.shared.apply("glasses", "controllerFullyBooted", false)
6150
- DeviceStore.shared.apply("glasses", "controllerSearching", true)
6341
+ // The ring↔glasses link dropped. The phone↔ring link owns
6342
+ // controllerConnected/FullyBooted, so don't clear them here
6343
+ // (that would flap pairing state) — just re-request the link.
6344
+ Bridge.log("G2: Ring-glasses link dropped, re-requesting")
6151
6345
  reconnectController()
6152
6346
  }
6153
6347
 
@@ -6218,9 +6412,22 @@ final class G2: NSObject, SGCManager {
6218
6412
  definitive: cmdValue == G2SettingCommandId.deviceReceiveRequest.rawValue
6219
6413
  )
6220
6414
  publishFirmwareVersion()
6415
+ publishSceneCanvas()
6221
6416
  }
6222
6417
  }
6223
6418
 
6419
+ /// Publish the CFW scene canvas into the glasses status once classification
6420
+ /// lands. The engine's getEffectiveCapabilities keys its lifted element
6421
+ /// caps off this (stock's 6-text/4-image container pools don't bound a
6422
+ /// phone-rasterized CFW frame). Zeros on stock so a stale value can't
6423
+ /// survive a CFW→stock swap.
6424
+ private func publishSceneCanvas() {
6425
+ guard cfwClassified else { return }
6426
+ let active = supportsCfwSceneRendering
6427
+ DeviceStore.shared.apply("glasses", "sceneCanvasWidth", active ? 576 : 0)
6428
+ DeviceStore.shared.apply("glasses", "sceneCanvasHeight", active ? 288 : 0)
6429
+ }
6430
+
6224
6431
  private func updateCfwCapabilities(_ data: Data?, definitive: Bool) {
6225
6432
  // The sticky/definitive rules live in G2CfwClassification (unit-tested);
6226
6433
  // nil means the packet proved nothing and prior identity stands.
@@ -6533,14 +6740,14 @@ extension G2: CBCentralManagerDelegate {
6533
6740
  if self.leftPeripheral == nil {
6534
6741
  self.leftPeripheral = peripheral
6535
6742
  peripheral.delegate = self
6536
- central.connect(peripheral, options: nil)
6743
+ central.connect(peripheral, options: G2.peripheralConnectOptions)
6537
6744
  // Bridge.log("G2: Connecting to LEFT: \(name)")
6538
6745
  }
6539
6746
  } else if name.contains("_R_") {
6540
6747
  if self.rightPeripheral == nil {
6541
6748
  self.rightPeripheral = peripheral
6542
6749
  peripheral.delegate = self
6543
- central.connect(peripheral, options: nil)
6750
+ central.connect(peripheral, options: G2.peripheralConnectOptions)
6544
6751
  // Bridge.log("G2: Connecting to RIGHT: \(name)")
6545
6752
  }
6546
6753
  }
@@ -6553,6 +6760,26 @@ extension G2: CBCentralManagerDelegate {
6553
6760
  }
6554
6761
  }
6555
6762
 
6763
+ nonisolated func centralManager(
6764
+ _: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral
6765
+ ) {
6766
+ let authorized = peripheral.ancsAuthorized
6767
+ DispatchQueue.main.async { [weak self] in
6768
+ guard let self = self else { return }
6769
+ let side = peripheral === self.leftPeripheral ? "LEFT" : "RIGHT"
6770
+ Bridge.log("G2: ANCS authorization changed on \(side): \(authorized)")
6771
+ self.publishAncsAuthorization()
6772
+ }
6773
+ }
6774
+
6775
+ /// Either lens being ANCS-authorized is enough — the firmware's ANCS client
6776
+ /// runs on whichever arm holds the phone link.
6777
+ private func publishAncsAuthorization() {
6778
+ let authorized = (leftPeripheral?.ancsAuthorized ?? false)
6779
+ || (rightPeripheral?.ancsAuthorized ?? false)
6780
+ DeviceStore.shared.apply("glasses", "ancsAuthorized", authorized)
6781
+ }
6782
+
6556
6783
  nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
6557
6784
  DispatchQueue.main.async { [weak self] in
6558
6785
  guard let self = self else { return }
@@ -6601,12 +6828,16 @@ extension G2: CBCentralManagerDelegate {
6601
6828
  self.leftWriteChar = nil
6602
6829
  self.leftNotifyChar = nil
6603
6830
  self.leftAudioChar = nil
6831
+ self.leftFileWriteChar = nil
6832
+ self.leftFileNotifyChar = nil
6604
6833
  self.leftAuthenticated = false
6605
6834
  } else {
6606
6835
  self.rightInitialized = false
6607
6836
  self.rightWriteChar = nil
6608
6837
  self.rightNotifyChar = nil
6609
6838
  self.rightAudioChar = nil
6839
+ self.rightFileWriteChar = nil
6840
+ self.rightFileNotifyChar = nil
6610
6841
  self.rightAuthenticated = false
6611
6842
  }
6612
6843
  Bridge.log("G2: \(side) rebooted during firmware flash; preserving the peer link")
@@ -6626,6 +6857,10 @@ extension G2: CBCentralManagerDelegate {
6626
6857
  self.rightNotifyChar = nil
6627
6858
  self.leftAudioChar = nil
6628
6859
  self.rightAudioChar = nil
6860
+ self.leftFileWriteChar = nil
6861
+ self.rightFileWriteChar = nil
6862
+ self.leftFileNotifyChar = nil
6863
+ self.rightFileNotifyChar = nil
6629
6864
  self.authStarted = false
6630
6865
  self.leftAuthenticated = false
6631
6866
  self.rightAuthenticated = false
@@ -6749,6 +6984,21 @@ extension G2: CBPeripheralDelegate {
6749
6984
  self.rightAudioChar = char
6750
6985
  }
6751
6986
  peripheral.setNotifyValue(true, for: char)
6987
+ } else if uuid == G2BLE.FILE_WRITE {
6988
+ Bridge.log("G2: Found FILE WRITE char on \(side)")
6989
+ if peripheral === self.leftPeripheral {
6990
+ self.leftFileWriteChar = char
6991
+ } else {
6992
+ self.rightFileWriteChar = char
6993
+ }
6994
+ } else if uuid == G2BLE.FILE_NOTIFY {
6995
+ Bridge.log("G2: Found FILE NOTIFY char on \(side)")
6996
+ if peripheral === self.leftPeripheral {
6997
+ self.leftFileNotifyChar = char
6998
+ } else {
6999
+ self.rightFileNotifyChar = char
7000
+ }
7001
+ peripheral.setNotifyValue(true, for: char)
6752
7002
  }
6753
7003
  }
6754
7004
 
@@ -6812,6 +7062,13 @@ extension G2: CBPeripheralDelegate {
6812
7062
  } else if characteristic.uuid == G2BLE.CHAR_NOTIFY {
6813
7063
  // Protocol data
6814
7064
  await self.handleNotifyData(data, from: peripheral)
7065
+ } else if characteristic.uuid == G2BLE.FILE_NOTIFY {
7066
+ // File-transfer responses (CACHE_HIT/MISS, transfer status) —
7067
+ // observability for the notify-whitelist push.
7068
+ let side = peripheral === self.leftPeripheral ? "L" : "R"
7069
+ Bridge.log(
7070
+ "G2: FILE notify (\(side)): \(data.map { String(format: "%02X", $0) }.joined())"
7071
+ )
6815
7072
  }
6816
7073
  }
6817
7074
  }