craft-native 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.
@@ -894,6 +894,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
894
894
  accessory.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
895
895
  accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) }
896
896
  tabViews.forEachIndexed { index, (icon, label) ->
897
+ tabBar.getChildAt(index).isSelected = index == selectedTab
897
898
  val color = if (index == selectedTab) accent else muted
898
899
  val number = tabIconText[tabs[index].id]
899
900
  icon.setTextColor(if (number != null) Color.parseColor("#FF2D55") else color)
@@ -3780,6 +3780,8 @@ class MainActivity : AppCompatActivity() {
3780
3780
  private var tabSwipeTarget: Screen? = null
3781
3781
  private var tabSwipeTargetIndex = -1
3782
3782
  private var tabSwipeDirection = 0
3783
+ private var tabSwipeGeneration = 0
3784
+ private var tabSwipeSettling = false
3783
3785
  /** The app script's isolate (`nativeApp`), when the app has one. */
3784
3786
  private var appScreen: Screen? = null
3785
3787
  private val urlSchemes = mutableSetOf<String>()
@@ -3960,6 +3962,7 @@ class MainActivity : AppCompatActivity() {
3960
3962
  bars.onAccessory = { path -> navigateOpen(path) }
3961
3963
  bars.onButton = { id -> visibleScreen?.takeIf { it.active }?.let { send(it, "NAV_BUTTON", JSONObject().put("id", id)) } }
3962
3964
  bars.onTab = { index ->
3965
+ interruptTabSwipe()
3963
3966
  haptic()
3964
3967
  if (index == currentTab) popToRoot() else selectTab(index)
3965
3968
  }
@@ -3996,6 +3999,7 @@ class MainActivity : AppCompatActivity() {
3996
3999
  /** A tab chosen: its stack shows, its root screen opened the first time. */
3997
4000
  private fun selectTab(index: Int) {
3998
4001
  if (index !in tabStacks.indices) return
4002
+ interruptTabSwipe()
3999
4003
  currentTab = index
4000
4004
  screens = tabStacks[index]
4001
4005
  chrome?.selectTab(index)
@@ -4017,6 +4021,7 @@ class MainActivity : AppCompatActivity() {
4017
4021
 
4018
4022
  /** Start an interactive horizontal transition to the adjacent tab. */
4019
4023
  private fun beginTabSwipe(dx: Float, x: Float, y: Float): Boolean {
4024
+ if (tabSwipeSettling) interruptTabSwipe()
4020
4025
  if (tabSwipeTarget != null || screens.size != 1 || chrome?.tabSwipeEnabled != true) return false
4021
4026
  val direction = if (dx < 0) 1 else -1
4022
4027
  val targetIndex = currentTab + direction
@@ -4055,16 +4060,23 @@ class MainActivity : AppCompatActivity() {
4055
4060
  val host = chrome?.content ?: return
4056
4061
  val width = host.width.toFloat().coerceAtLeast(1f)
4057
4062
  val complete = !cancelled && kotlin.math.abs(dx) >= width * 0.28f
4063
+ val generation = ++tabSwipeGeneration
4064
+ val targetIndex = tabSwipeTargetIndex
4065
+ tabSwipeSettling = true
4058
4066
  val endOffset = if (complete) {
4059
4067
  if (tabSwipeDirection > 0) -width else width
4060
4068
  } else 0f
4061
4069
  val targetOffset = if (complete) 0f else if (tabSwipeDirection > 0) width else -width
4062
4070
  current.view.animate().translationX(endOffset).setDuration(180).start()
4063
4071
  target.view.animate().translationX(targetOffset).setDuration(180).withEndAction {
4072
+ // A later tab tap or gesture owns the display even if Android
4073
+ // delivers this animator's completion after it was cancelled.
4074
+ if (generation != tabSwipeGeneration || tabSwipeTarget !== target || visibleScreen !== current) return@withEndAction
4064
4075
  target.view.animate().setListener(null)
4076
+ tabSwipeSettling = false
4065
4077
  if (complete) {
4066
4078
  current.view.translationX = 0f
4067
- currentTab = tabSwipeTargetIndex
4079
+ currentTab = targetIndex
4068
4080
  screens = tabStacks[currentTab]
4069
4081
  tabSwipeTarget = null
4070
4082
  tabSwipeTargetIndex = -1
@@ -4082,6 +4094,24 @@ class MainActivity : AppCompatActivity() {
4082
4094
  }.start()
4083
4095
  }
4084
4096
 
4097
+ /** Restore the owning stack before a newer navigation action takes over. */
4098
+ private fun interruptTabSwipe() {
4099
+ val target = tabSwipeTarget ?: return
4100
+ ++tabSwipeGeneration
4101
+ tabSwipeTarget = null
4102
+ tabSwipeTargetIndex = -1
4103
+ tabSwipeDirection = 0
4104
+ tabSwipeSettling = false
4105
+ val current = visibleScreen
4106
+ current?.view?.animate()?.withEndAction(null)?.setListener(null)?.cancel()
4107
+ target.view.animate().withEndAction(null).setListener(null).cancel()
4108
+ current?.view?.translationX = 0f
4109
+ target.view.translationX = 0f
4110
+ if (target !== current) (target.view.parent as? ViewGroup)?.removeView(target.view)
4111
+ chrome?.selectTab(currentTab)
4112
+ current?.let(::applyChrome)
4113
+ }
4114
+
4085
4115
  /**
4086
4116
  * Returns true when the exact screen gesture point is over a horizontal
4087
4117
  * child that still has room in the gesture's direction. Screen coordinates
@@ -4172,6 +4202,7 @@ class MainActivity : AppCompatActivity() {
4172
4202
  }
4173
4203
 
4174
4204
  private fun openScreen(route: String?, params: JSONObject = JSONObject(), headless: Boolean = false, showImmediately: Boolean = true): Screen? {
4205
+ if (showImmediately && !headless) interruptTabSwipe()
4175
4206
  val engine = sandbox ?: return null
4176
4207
  val isolate = engine.createIsolate()
4177
4208
  val view = CraftNativeFlexLayout(this).apply {
@@ -6943,6 +6974,7 @@ class MainActivity : AppCompatActivity() {
6943
6974
 
6944
6975
  /** Back to the first screen in one move. */
6945
6976
  private fun popToRoot() {
6977
+ interruptTabSwipe()
6946
6978
  val stack = screens
6947
6979
  val removed = mutableListOf<Screen>()
6948
6980
  while (stack.size > 1) removed.add(stack.removeAt(stack.lastIndex))
@@ -6954,6 +6986,7 @@ class MainActivity : AppCompatActivity() {
6954
6986
 
6955
6987
  /** Back: the screen below in this tab, then the first tab, then out of the app. */
6956
6988
  private fun back() {
6989
+ interruptTabSwipe()
6957
6990
  // A previous back is still draining its screen's teardown messages.
6958
6991
  if (visibleScreen == null) return
6959
6992
  if (screens.size > 1) {
@@ -7281,6 +7314,7 @@ class MainActivity : AppCompatActivity() {
7281
7314
  }
7282
7315
 
7283
7316
  override fun onDestroy() {
7317
+ interruptTabSwipe()
7284
7318
  if (animationScaleObserverRegistered) contentResolver.unregisterContentObserver(animationScaleObserver)
7285
7319
  CraftNativeBackground.foreground = null
7286
7320
  backgroundWaiters.values.forEach { it(false) }
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.221";
3427
+ var version = "0.0.222";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.221";
312
+ var version = "0.0.222";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.221";
60
+ var version = "0.0.222";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.221",
3
+ "version": "0.0.222",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",