craft-native 0.0.220 → 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/dist/android/templates/CraftNativeShell.kt.template +1 -0
- package/dist/android/templates/MainActivityNative.kt.template +35 -1
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +52 -8
- package/dist/ios/templates/CraftHeartRate.swift +115 -12
- package/dist/ios/templates/CraftNativeActions.swift +6 -3
- package/package.json +1 -1
|
@@ -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 =
|
|
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.
|
|
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
package/dist/index.js
CHANGED
|
@@ -1105,23 +1105,58 @@ struct CraftSplashView: View {
|
|
|
1105
1105
|
}
|
|
1106
1106
|
|
|
1107
1107
|
// MARK: - Deep Link Manager
|
|
1108
|
+
/// Launch intent settles when UIKit delivers its URL or completes activation,
|
|
1109
|
+
/// independently of when a page or native screen registers its listeners.
|
|
1110
|
+
final class CraftInitialLinkState {
|
|
1111
|
+
private(set) var url: URL?
|
|
1112
|
+
private(set) var settled = false
|
|
1113
|
+
private var readers: [(URL?) -> Void] = []
|
|
1114
|
+
|
|
1115
|
+
func receive(_ url: URL) {
|
|
1116
|
+
guard !settled else { return }
|
|
1117
|
+
self.url = url
|
|
1118
|
+
settle()
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
func settle() {
|
|
1122
|
+
guard !settled else { return }
|
|
1123
|
+
settled = true
|
|
1124
|
+
let waiting = readers
|
|
1125
|
+
readers.removeAll()
|
|
1126
|
+
for reader in waiting { reader(url) }
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
func resolve(_ completion: @escaping (URL?) -> Void) {
|
|
1130
|
+
if settled { completion(url) }
|
|
1131
|
+
else { readers.append(completion) }
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1108
1135
|
class DeepLinkManager {
|
|
1109
1136
|
static let shared = DeepLinkManager()
|
|
1110
1137
|
|
|
1111
|
-
private
|
|
1138
|
+
private let launch = CraftInitialLinkState()
|
|
1139
|
+
private var initialURL: URL? { launch.url }
|
|
1140
|
+
private var activationObservers: [NSObjectProtocol] = []
|
|
1112
1141
|
// Every link that arrived while no page could receive it, in order. This
|
|
1113
1142
|
// was a single slot, so a second link before the bridge was ready replaced
|
|
1114
1143
|
// the first; on a cold start, that could be the link that launched the app.
|
|
1115
1144
|
private var pendingURLs: [URL] = []
|
|
1116
1145
|
private weak var webView: WKWebView?
|
|
1117
1146
|
private var isReady = false
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
// is no answer to getInitialURL.
|
|
1147
|
+
// Replay bookkeeping only. A native screen can become ready before UIKit
|
|
1148
|
+
// delivers the launch URL; listener readiness cannot classify that URL.
|
|
1121
1149
|
private var hasBeenReady = false
|
|
1122
1150
|
private var nativeListeners: [UUID: (URL, Bool) -> Void] = [:]
|
|
1123
1151
|
|
|
1124
|
-
private init() {
|
|
1152
|
+
private init() {
|
|
1153
|
+
for name in [UIApplication.didBecomeActiveNotification, UIApplication.didEnterBackgroundNotification] {
|
|
1154
|
+
activationObservers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
|
1155
|
+
self?.launch.settle()
|
|
1156
|
+
})
|
|
1157
|
+
}
|
|
1158
|
+
if UIApplication.shared.applicationState == .active { launch.settle() }
|
|
1159
|
+
}
|
|
1125
1160
|
|
|
1126
1161
|
func setWebView(_ webView: WKWebView) {
|
|
1127
1162
|
self.webView = webView
|
|
@@ -1146,9 +1181,7 @@ class DeepLinkManager {
|
|
|
1146
1181
|
}
|
|
1147
1182
|
|
|
1148
1183
|
func handleURL(_ url: URL) {
|
|
1149
|
-
|
|
1150
|
-
initialURL = url
|
|
1151
|
-
}
|
|
1184
|
+
launch.receive(url)
|
|
1152
1185
|
|
|
1153
1186
|
// A hybrid app opens a native path's screen itself, and tells the
|
|
1154
1187
|
// native screens that listen; every other link is the page's. An
|
|
@@ -1179,6 +1212,17 @@ class DeepLinkManager {
|
|
|
1179
1212
|
return initialURL
|
|
1180
1213
|
}
|
|
1181
1214
|
|
|
1215
|
+
/// A foreground scene can draw before SwiftUI delivers its launch activity.
|
|
1216
|
+
/// A headless background launch has no scene or foreground URL to await.
|
|
1217
|
+
func resolveInitialURL(_ completion: @escaping (URL?) -> Void) {
|
|
1218
|
+
let application = UIApplication.shared
|
|
1219
|
+
if application.applicationState == .active ||
|
|
1220
|
+
(application.applicationState == .background && application.connectedScenes.isEmpty) {
|
|
1221
|
+
launch.settle()
|
|
1222
|
+
}
|
|
1223
|
+
launch.resolve(completion)
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1182
1226
|
@discardableResult
|
|
1183
1227
|
func addNativeListener(_ listener: @escaping (URL, Bool) -> Void) -> UUID {
|
|
1184
1228
|
let token = UUID()
|
|
@@ -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":
|
|
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 {
|
|
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
|
-
//
|
|
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
|
|
526
|
-
|
|
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,
|
|
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
|
-
|
|
549
|
-
let
|
|
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 = (
|
|
777
|
+
recordingStartedAt = (clock().timeIntervalSince1970 * 1000).rounded()
|
|
675
778
|
recordingPaused = false
|
|
676
779
|
recordedCount = 0
|
|
677
780
|
persistRecording()
|
|
@@ -258,7 +258,10 @@ enum CraftNativeActions {
|
|
|
258
258
|
sync = .success(currentAppState())
|
|
259
259
|
case ("DeepLinks", "getInitialURL"):
|
|
260
260
|
if config.enableDeepLinks {
|
|
261
|
-
|
|
261
|
+
DeepLinkManager.shared.resolveInitialURL { url in
|
|
262
|
+
complete(requestToken, .success(claimInitialDeepLink(url).map(deepLinkData) ?? NSNull()), completion)
|
|
263
|
+
}
|
|
264
|
+
return
|
|
262
265
|
} else {
|
|
263
266
|
sync = failure("CAPABILITY_DISABLED", "Deep links are disabled")
|
|
264
267
|
}
|
|
@@ -809,12 +812,12 @@ enum CraftNativeActions {
|
|
|
809
812
|
else { task.resume() }
|
|
810
813
|
}
|
|
811
814
|
|
|
812
|
-
private static func claimInitialDeepLink() -> URL? {
|
|
815
|
+
private static func claimInitialDeepLink(_ url: URL?) -> URL? {
|
|
813
816
|
cancellationLock.lock()
|
|
814
817
|
defer { cancellationLock.unlock() }
|
|
815
818
|
guard !initialDeepLinkClaimed else { return nil }
|
|
816
819
|
initialDeepLinkClaimed = true
|
|
817
|
-
return
|
|
820
|
+
return url
|
|
818
821
|
}
|
|
819
822
|
|
|
820
823
|
private static func complete(
|