@craft-native/ios 0.0.221 → 0.0.222

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.221",
3
+ "version": "0.0.222",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -135,6 +135,13 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
135
135
  /// Fourteen hours of one reading a second: more is not one workout.
136
136
  static let maxRecordedSamples = 50_000
137
137
 
138
+ private let clock: () -> Date
139
+
140
+ init(now: @escaping () -> Date = Date.init) {
141
+ self.clock = now
142
+ super.init()
143
+ }
144
+
138
145
  /// The web view's coordinator, which sends an event to the page and every
139
146
  /// native screen. False (or unset, in an app without a web view) and the
140
147
  /// event goes to the native screens alone.
@@ -155,6 +162,12 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
155
162
  private var connectDeadline: DispatchWorkItem?
156
163
  private var latest: CraftHeartRateMeasurement?
157
164
  private var latestAt: Date?
165
+ // Packet freshness is separate from a valid BPM: a monitor reporting no
166
+ // skin contact is still delivering notifications and needs no reconnect.
167
+ private var packetAt: Date?
168
+ private var subscriptionAt: Date?
169
+ private var recoveryTimer: Timer?
170
+ private var hasSubscribed = false
158
171
 
159
172
  private var recordingId: String?
160
173
  private var recordingStartedAt: Double?
@@ -189,7 +202,9 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
189
202
  DispatchQueue.main.async {
190
203
  self.restoreRecording()
191
204
  switch method {
192
- case "getState": completion(.success(self.state()))
205
+ case "getState":
206
+ self.recoverQuietBroadcast()
207
+ completion(.success(self.state()))
193
208
  case "requestAuthorization": self.requestAuthorization(completion: completion)
194
209
  case "scan": self.scan(seconds: (options["timeout"] as? NSNumber)?.doubleValue, completion: completion)
195
210
  case "stopScan":
@@ -255,7 +270,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
255
270
  }
256
271
 
257
272
  func state() -> [String: Any] {
258
- let fresh = latestAt.map { Date().timeIntervalSince($0) <= Self.staleAfter } ?? false
273
+ let fresh = latestAt.map { clock().timeIntervalSince($0) <= Self.staleAfter } ?? false
259
274
  let device: Any = deviceId.map { ["id": $0.uuidString, "name": deviceName ?? "Heart rate monitor"] as [String: Any] } ?? NSNull()
260
275
  return [
261
276
  "available": bluetooth != "unsupported",
@@ -393,8 +408,10 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
393
408
  }
394
409
  whenReady {
395
410
  if let reason = self.unusable() { return completion(.failure(reason)) }
396
- // Already there, or on the way: nothing to start again.
411
+ // A connected link can lose its notification subscription when a
412
+ // broadcasting watch restarts. Keep healthy links, repair quiet ones.
397
413
  if self.deviceId == uuid && self.wantsConnection {
414
+ self.recoverQuietBroadcast()
398
415
  if self.connection == "connected" || !wait { return completion(.success(self.state())) }
399
416
  self.connectWaiters.append(completion)
400
417
  self.armConnectDeadline()
@@ -416,6 +433,8 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
416
433
  self.deviceName = name
417
434
  self.wantsConnection = true
418
435
  self.connection = "connecting"
436
+ self.subscriptionAt = self.clock()
437
+ self.startRecoveryWatch()
419
438
  self.remember(id: uuid, name: name)
420
439
  if wait {
421
440
  self.connectWaiters.append(completion)
@@ -454,6 +473,11 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
454
473
  /// Lets go of the current strap without telling anyone.
455
474
  private func release() {
456
475
  wantsConnection = false
476
+ hasSubscribed = false
477
+ recoveryTimer?.invalidate()
478
+ recoveryTimer = nil
479
+ packetAt = nil
480
+ subscriptionAt = nil
457
481
  simulatedTimer?.invalidate()
458
482
  simulatedTimer = nil
459
483
  if let peripheral, let central, peripheral.state != .disconnected { central.cancelPeripheralConnection(peripheral) }
@@ -474,7 +498,9 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
474
498
  }
475
499
 
476
500
  func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
477
- guard peripheral.identifier == deviceId else { return }
501
+ guard peripheral.identifier == deviceId, wantsConnection else { return }
502
+ packetAt = nil
503
+ subscriptionAt = clock()
478
504
  peripheral.delegate = self
479
505
  peripheral.discoverServices([Self.serviceUUID])
480
506
  }
@@ -491,6 +517,8 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
491
517
  guard peripheral.identifier == deviceId else { return }
492
518
  latest = nil
493
519
  latestAt = nil
520
+ packetAt = nil
521
+ subscriptionAt = clock()
494
522
  if wantsConnection {
495
523
  connection = "reconnecting"
496
524
  central.connect(peripheral)
@@ -501,7 +529,10 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
501
529
  }
502
530
 
503
531
  func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
532
+ guard peripheral.identifier == deviceId, wantsConnection else { return }
533
+ if error != nil { return restartBroadcast() }
504
534
  guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
535
+ if hasSubscribed { return restartBroadcast() }
505
536
  notAHeartRateMonitor(peripheral)
506
537
  return
507
538
  }
@@ -509,7 +540,10 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
509
540
  }
510
541
 
511
542
  func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
543
+ guard peripheral.identifier == deviceId, wantsConnection, service.uuid == Self.serviceUUID else { return }
544
+ if error != nil { return restartBroadcast() }
512
545
  guard let measurement = service.characteristics?.first(where: { $0.uuid == Self.measurementUUID }) else {
546
+ if hasSubscribed { return restartBroadcast() }
513
547
  notAHeartRateMonitor(peripheral)
514
548
  return
515
549
  }
@@ -517,36 +551,104 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
517
551
  }
518
552
 
519
553
  func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
520
- guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId else { return }
521
- if characteristic.isNotifying {
554
+ guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId, wantsConnection else { return }
555
+ if characteristic.isNotifying && error == nil {
556
+ hasSubscribed = true
557
+ subscriptionAt = clock()
522
558
  connection = "connected"
523
559
  emitState()
524
560
  settleConnect(.success(state()))
525
- } else if let error {
526
- settleConnect(.failure(CraftHeartRateError(message: error.localizedDescription, code: "CONNECT_FAILED")))
561
+ } else {
562
+ restartBroadcast()
527
563
  }
528
564
  }
529
565
 
566
+ // Garmin may invalidate its service when HR broadcast is toggled without
567
+ // dropping the physical link. Discover and subscribe to the replacement.
568
+ func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
569
+ guard peripheral.identifier == deviceId, wantsConnection,
570
+ invalidatedServices.contains(where: { $0.uuid == Self.serviceUUID }) else { return }
571
+ connection = "reconnecting"
572
+ latest = nil
573
+ latestAt = nil
574
+ packetAt = nil
575
+ subscriptionAt = clock()
576
+ peripheral.discoverServices([Self.serviceUUID])
577
+ emitState()
578
+ }
579
+
530
580
  func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
531
- guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId, let value = characteristic.value else { return }
581
+ guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId, wantsConnection else { return }
582
+ if error != nil { return restartBroadcast() }
583
+ guard let value = characteristic.value else { return }
532
584
  received([UInt8](value))
533
585
  }
534
586
 
535
587
  private func notAHeartRateMonitor(_ peripheral: CBPeripheral) {
536
588
  wantsConnection = false
589
+ hasSubscribed = false
590
+ recoveryTimer?.invalidate()
591
+ recoveryTimer = nil
537
592
  central?.cancelPeripheralConnection(peripheral)
538
593
  connection = "disconnected"
539
594
  settleConnect(.failure(CraftHeartRateError(message: "That device does not send a heart rate", code: "NOT_HEART_RATE_MONITOR")))
540
595
  emitState()
541
596
  }
542
597
 
598
+ /// Run independently of any player or big-timer screen. A pending radio
599
+ /// connection stays pending until the watch returns; only a connected link
600
+ /// with a silent subscription is restarted.
601
+ private func startRecoveryWatch() {
602
+ recoveryTimer?.invalidate()
603
+ recoveryTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
604
+ self?.recoverQuietBroadcast()
605
+ }
606
+ }
607
+
608
+ private func recoverQuietBroadcast() {
609
+ guard wantsConnection, let since = packetAt ?? subscriptionAt else { return }
610
+ let quiet = clock().timeIntervalSince(since)
611
+ if connection == "connected" && quiet > 20 {
612
+ restartBroadcast()
613
+ } else if connection != "connected", peripheral?.state == .connected, quiet > 30 {
614
+ restartBroadcast()
615
+ }
616
+ }
617
+
618
+ /// Keep the recording and device choice while replacing the subscription.
619
+ /// CoreBluetooth's disconnect callback starts the next connection once the
620
+ /// previous one has actually closed.
621
+ private func restartBroadcast() {
622
+ guard wantsConnection else { return }
623
+ connection = "reconnecting"
624
+ latest = nil
625
+ latestAt = nil
626
+ packetAt = nil
627
+ subscriptionAt = clock()
628
+ emitState()
629
+ if simulated {
630
+ simulatedTimer?.invalidate()
631
+ simulatedTimer = nil
632
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.simulatedConnected() }
633
+ } else if let peripheral, let central, central.state == .poweredOn {
634
+ if peripheral.state == .disconnected { central.connect(peripheral) }
635
+ else if peripheral.state != .disconnecting { central.cancelPeripheralConnection(peripheral) }
636
+ }
637
+ }
638
+
543
639
  // MARK: Readings
544
640
 
545
641
  private func received(_ bytes: [UInt8]) {
546
642
  // A strap reads 0 between losing the skin and giving up; that is no
547
643
  // heart rate, and neither is anything a heart could not do.
548
- guard let reading = CraftHeartRateMeasurement.parse(bytes), reading.bpm >= 20, reading.bpm <= 250 else { return }
549
- let now = Date()
644
+ packetAt = clock()
645
+ guard let reading = CraftHeartRateMeasurement.parse(bytes), reading.sensorContact != false, reading.bpm >= 20, reading.bpm <= 250 else {
646
+ latest = nil
647
+ latestAt = nil
648
+ emitState()
649
+ return
650
+ }
651
+ let now = clock()
550
652
  latest = reading
551
653
  latestAt = now
552
654
  let timestamp = (now.timeIntervalSince1970 * 1000).rounded()
@@ -567,6 +669,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
567
669
  private func simulatedConnected() {
568
670
  guard simulated, wantsConnection, deviceId == Self.simulatedId else { return }
569
671
  connection = "connected"
672
+ subscriptionAt = clock()
570
673
  emitState()
571
674
  settleConnect(.success(state()))
572
675
  simulatedTimer?.invalidate()
@@ -671,7 +774,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
671
774
  return completion(.failure(CraftHeartRateError(message: "Unable to create the heart-rate recording", code: "RECORDING_STORAGE_ERROR")))
672
775
  }
673
776
  recordingId = UUID().uuidString
674
- recordingStartedAt = (Date().timeIntervalSince1970 * 1000).rounded()
777
+ recordingStartedAt = (clock().timeIntervalSince1970 * 1000).rounded()
675
778
  recordingPaused = false
676
779
  recordedCount = 0
677
780
  persistRecording()