From 6be73dafacfb6efae9097baef59e5e6bf93d2294 Mon Sep 17 00:00:00 2001 From: ahmadssadiq Date: Mon, 19 Oct 2026 16:23:41 -0400 Subject: [PATCH 1/4] fix(coach): retry streamed replies with max_completion_tokens The Coach streams its replies, and the streaming path in the OpenAI and Custom providers always sent `max_tokens` + `temperature`. Newer OpenAI models (the reasoning / GPT-5 families) reject that with: 400 - Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead. `send()` already retried that 400 with the modern parameter shape, but `stream()` had no retry (its comment said it fell back to `send`'s retry, which nothing did), so the Coach surfaced the raw provider error. `stream()` now mirrors `send()`: try the standard shape first, and on a 400 that mentions max_tokens / max_completion_tokens / temperature / "unsupported", re-stream with `max_completion_tokens` and no temperature. The 400 arrives before any delta is emitted, so re-streaming can't duplicate text. The body builder moved into a private `streamChat(...)` with a `modernParams` flag, matching the existing `chat(...)` helper. Builds and runs on an iPhone 16 Pro (iOS 26); not yet verified against a live OpenAI reasoning model. Co-Authored-By: Claude Opus 5.5 --- Strand/AI/Providers/Custom.swift | 38 ++++++++++++++++++++++++++------ Strand/AI/Providers/OpenAI.swift | 32 +++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/Strand/AI/Providers/Custom.swift b/Strand/AI/Providers/Custom.swift index a52b33c229..f441e01752 100644 --- a/Strand/AI/Providers/Custom.swift +++ b/Strand/AI/Providers/Custom.swift @@ -47,13 +47,37 @@ struct CustomClient: AIProviderClient { var wire: [[String: Any]] = [["role": "system", "content": systemPrompt]] for m in messages { wire.append(["role": m.role.rawValue, "content": m.content]) } - let body: [String: Any] = [ - "model": model, - "messages": wire, - "temperature": 0.6, - "max_tokens": 4096, - "stream": true - ] + // Same modern-params retry as `send`: the 400 arrives before any delta, so re-streaming is safe. + do { + try await streamChat(key: key, model: model, wire: wire, modernParams: false, + session: session, onDelta: onDelta) + } catch let AICoachError.server(code, detail) where code == 400 { + let d = detail.lowercased() + if d.contains("max_completion_tokens") || d.contains("max_tokens") + || d.contains("temperature") || d.contains("unsupported") { + try await streamChat(key: key, model: model, wire: wire, modernParams: true, + session: session, onDelta: onDelta) + return + } + throw AICoachError.server(code, detail) + } + } + + private func streamChat( + key: String, + model: String, + wire: [[String: Any]], + modernParams: Bool, + session: URLSession, + onDelta: (String) -> Void + ) async throws { + var body: [String: Any] = ["model": model, "messages": wire, "stream": true] + if modernParams { + body["max_completion_tokens"] = 4096 + } else { + body["temperature"] = 0.6 + body["max_tokens"] = 4096 + } var req = URLRequest(url: AIProvider.custom.endpoint) req.httpMethod = "POST" diff --git a/Strand/AI/Providers/OpenAI.swift b/Strand/AI/Providers/OpenAI.swift index 78f06f5658..c5015e017c 100644 --- a/Strand/AI/Providers/OpenAI.swift +++ b/Strand/AI/Providers/OpenAI.swift @@ -41,9 +41,37 @@ struct OpenAIClient: AIProviderClient { var wire: [[String: Any]] = [["role": "system", "content": systemPrompt]] for m in messages { wire.append(["role": m.role.rawValue, "content": m.content]) } + // Same modern-params retry as `send`: the 400 arrives before any delta, so re-streaming is safe. + do { + try await streamChat(key: key, model: model, wire: wire, modernParams: false, + session: session, onDelta: onDelta) + } catch let AICoachError.server(code, detail) where code == 400 { + let d = detail.lowercased() + if d.contains("max_completion_tokens") || d.contains("max_tokens") + || d.contains("temperature") || d.contains("unsupported") { + try await streamChat(key: key, model: model, wire: wire, modernParams: true, + session: session, onDelta: onDelta) + return + } + throw AICoachError.server(code, detail) + } + } + + private func streamChat( + key: String, + model: String, + wire: [[String: Any]], + modernParams: Bool, + session: URLSession, + onDelta: (String) -> Void + ) async throws { var body: [String: Any] = ["model": model, "messages": wire, "stream": true] - body["temperature"] = 0.6 - body["max_tokens"] = 4096 + if modernParams { + body["max_completion_tokens"] = 4096 + } else { + body["temperature"] = 0.6 + body["max_tokens"] = 4096 + } var req = URLRequest(url: AIProvider.openAI.endpoint) req.httpMethod = "POST" From ca5a2ea03e6b42c43b0e3105c321c8acf1e68bdc Mon Sep 17 00:00:00 2001 From: ahmadssadiq Date: Mon, 19 Oct 2026 16:24:02 -0400 Subject: [PATCH 2/4] feat(ios): buzz the strap while an incoming call rings iOS counterpart of the Android incoming-call wrist alert (#66): when a call rings on the iPhone, the WHOOP strap buzzes, like an Apple Watch, until the call is answered, declined or ends. Detection: CallKit's CXCallObserver. It sees every CallKit call on the phone (regular phone calls, FaceTime, and VoIP apps that ring through CallKit, e.g. WhatsApp voice and video) and needs no permission or entitlement. A call counts as ringing when it is incoming, not yet connected and not ended. Only that state is read: never the number, the caller or the app. Why not ANCS: ANCS has the accessory (the strap) subscribe to the iPhone's notification service, which needs WHOOP firmware support. The strap doesn't do that, so the phone-side app has to relay the call. Background behaviour: iOS only delivers CXCallObserver changes while the app runs. A backgrounded NOOP runs when the strap wakes it with a BLE frame, so BLEManager now fires `onInboundFrame` on every notification and the monitor re-checks the call list there (throttled to 1 Hz). With Settings > Continuous HRV capture on (and "Overnight only" off) the strap sends a frame every second, so alerts fire within about a second with the app in the foreground, in the background, or with the phone locked. A short beginBackgroundTask covers the start of each ring. It cannot work after the app is force-quit from the app switcher (iOS does not relaunch it). The monitor is created in AppModel.init, so it is live after a CoreBluetooth state-restoration relaunch too. Cadence: one burst (3 pulses, ~2 s on the strap per its own log) every 3 s while ringing, stopping within ~1 s of the call ending. A 90 s ceiling self-heals a call whose end is never observed. This differs from Android, which stops after 4 buzzes at 8 s intervals. Haptics (5/MG): the call buzz is the hardware-confirmed maverick notify buzz (cmd 0x13), written with response so a backgrounded link can't drop it, and without RUN_ALARM so repeats never start the alarm. It is one notch softer than other buzzes: the strap's driver is a TI DRV2625 (named in the strap log), and the call buzz uses ROM effect 48 ("Buzz 2 - 80%") instead of 47 ("Buzz 1 - 100%"). To support that: - MaverickHaptics.notificationBuzz(loops:effect:) takes the first waveform effect (default 47, so existing bodies are byte-identical; the second slot, 152 = 0x80|24, is a 240 ms pause and is unchanged). - BLEManager.send(_:payload:writeType:maverickEffect:) passes it through (default 47, so every existing caller is unchanged). Other strap families use the existing buzzStrapOnce() sequence. Settings: new "Buzz strap for incoming calls" toggle (default off, key notif.calls.enabled) under Auto-detect workouts, with captions covering what rings it, the privacy line, and the Continuous HRV capture requirement for background alerts. Tested on an iPhone 16 Pro (iOS 26) + WHOOP 5.0: the first version (single burst every few seconds) buzzed for phone calls with the app open, in the background and with the phone locked. The strap log showed the 0x13 frame and "HAPTICS: Sequence fired". The continuous cadence and the 80% effect are built and installed but not yet confirmed by feel. Co-Authored-By: Claude Opus 5.5 --- .../WhoopProtocol/HapticPayloads.swift | 9 +- Strand/App/AppModel.swift | 18 +++ Strand/BLE/BLEManager.swift | 23 ++- Strand/Screens/SettingsView.swift | 23 +++ StrandiOS/App/CallBuzzMonitor.swift | 141 ++++++++++++++++++ 5 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 StrandiOS/App/CallBuzzMonitor.swift diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/HapticPayloads.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/HapticPayloads.swift index c07bcae371..1746606215 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/HapticPayloads.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/HapticPayloads.swift @@ -40,8 +40,13 @@ public enum MaverickHaptics { /// `UInt8(clamping:)` admitted 255, a value nothing has ever observed on the wire. /// /// Kotlin twin: `WhoopBleClient.maverickHapticBody`. - public static func notificationBuzz(loops: Int) -> [UInt8] { - [0x01] + waveformEffects + [0x00, 0x00, UInt8(min(max(loops, 1), 8) - 1)] + public static func notificationBuzz(loops: Int, effect: UInt8 = 47) -> [UInt8] { + // `effect` swaps the first waveform slot. The strap's haptic driver is a TI DRV2625 (it names it + // in its own log), whose ROM library has 47 = "Buzz 1 – 100%" and 48…51 = 80/60/40/20%. The + // second slot (152 = 0x80 | 24) is a 240 ms pause, kept as is. Only 47 is hardware-confirmed. + var effects = waveformEffects + effects[0] = effect + return [0x01] + effects + [0x00, 0x00, UInt8(min(max(loops, 1), 8) - 1)] } } diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index b477b8b992..87548a497b 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -56,6 +56,10 @@ final class AppModel: ObservableObject { let live: LiveState /// CoreBluetooth engine , scans, connects, bonds, streams. let ble: BLEManager + #if os(iOS) + /// Buzzes the strap while an incoming call rings (Settings → Buzz strap for incoming calls). + private var callBuzz: CallBuzzMonitor? + #endif /// Read model over the on-device store (dashboard + detail screens). let repo: Repository /// User profile (age/sex/body/HR-max) for zones, calories, baselines. @@ -542,6 +546,20 @@ final class AppModel: ObservableObject { try? await Task.sleep(nanoseconds: 1_800_000_000_000) // 30 min backstop (#836 battery) } } + + #if os(iOS) + // Incoming-call wrist alert. Created at launch (including a Bluetooth state-restoration relaunch + // into the background) so it is watching before any screen opens. + let callBuzz = CallBuzzMonitor( + buzz: { [ble, live] in + guard live.connected else { return false } + ble.buzzCallAlert() + return true + }, + log: { [live] line in live.append(log: line) }) + self.callBuzz = callBuzz + ble.onInboundFrame = { [weak callBuzz] in callBuzz?.poll() } + #endif } /// Build the device registry + source coordinator once the store is open, then start observing. diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 690e394194..d3798e1bb6 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -577,6 +577,9 @@ public final class BLEManager: NSObject, ObservableObject { /// (`DeviceRegistry`, and the coordinator that watches it) follows too — the store write above is not /// observable, so without this the UI would keep showing the old id until relaunch. var onSerialIdentityAdopted: ((String) -> Void)? + /// Fired on every inbound notification. Each one is a moment iOS lets a backgrounded app run, so + /// the incoming-call alert uses it to re-check for a ringing call (`CallBuzzMonitor.poll`). + var onInboundFrame: (() -> Void)? private var registryStore: DeviceRegistryStore? /// #716: true once the seeded "WHOOP" model has been stamped to the correct family. private var modelStamped = false @@ -2279,7 +2282,8 @@ public final class BLEManager: NSObject, ObservableObject { /// - writeType: BLE write type; defaults to `.withoutResponse` so all existing call /// sites are unaffected. Pass `.withResponse` for acked commands (e.g. historicalDataResult). public func send(_ command: WhoopCommand, payload: [UInt8] = [0x00], - writeType: CBCharacteristicWriteType = .withoutResponse) { + writeType: CBCharacteristicWriteType = .withoutResponse, + maverickEffect: UInt8 = 47) { // 5/MG buzz strength, see MaverickHaptics.notificationBuzz // #314 parity: CoreBluetooth already covers both Android defects here — this `p.state == .connected` // guard makes a write a no-op once the radio powers off (no DeadObjectException to crash on), and // centralManagerDidUpdateState publishes state.connected = false on .poweredOff, so the iOS/macOS UI @@ -2426,7 +2430,7 @@ public final class BLEManager: NSObject, ObservableObject { // that asked for one. Kotlin twin: `WhoopBleClient.maverickHapticBody`. let buzzLoops = payload.count >= 2 ? Int(payload[1]) : 1 let puffinPayload: [UInt8] = isHaptics - ? MaverickHaptics.notificationBuzz(loops: buzzLoops) : payload + ? MaverickHaptics.notificationBuzz(loops: buzzLoops, effect: maverickEffect) : payload seq = seq &+ 1 let frame = puffinCommandFrame(cmd: puffinCmd, seq: seq, payload: puffinPayload) p.writeValue(Data(frame), for: ch, type: writeType) @@ -5592,6 +5596,20 @@ public final class BLEManager: NSObject, ObservableObject { log("Buzz: one-shot fired (patternId=2 loops=3 + runAlarm, acked)") } + /// Incoming-call wrist alert (`CallBuzzMonitor`). On a 5/MG this is the maverick notify buzz alone + /// (the same hardware-confirmed one-shot Haptic Clock uses), acknowledged so a backgrounded link + /// can't silently drop it, and without RUN_ALARM so a repeated call buzz never starts the alarm. + /// One notch softer than other buzzes: DRV2625 effect 48 ("Buzz 2 – 80%") instead of 47 (100%). + /// Other families take the full one-shot sequence, since a bare pattern write was seen ignored on a + /// WHOOP 4.0 (#921). + func buzzCallAlert() { + guard selectedModel.deviceFamily == .whoop5 else { + buzzStrapOnce() + return + } + send(.runHapticsPattern, payload: [2, 3, 0, 0, 0], writeType: .withResponse, maverickEffect: 48) + } + /// Haptic Clock (#460): buzz the current wall-clock time out on the strap so the user can read it /// off their wrist without a screen. The pure, unit-tested `HapticClock` encoder turns now into an /// ordered pulse list (long = a "ten", short = a "unit", in HH-tens / HH-units / MM-tens / MM-units @@ -7313,6 +7331,7 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { guard let data = characteristic.value else { return } let bytes = [UInt8](data) lastDataAt = Date() // feed the liveness watchdog on every notification + onInboundFrame?() // #1809: count BEFORE the per-characteristic switch below, so the tally covers every inbound // frame including ones no branch consumes - the epitaph must answer "did anything arrive at all". inboundFrames += 1 diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 48e3a5e8df..d3384e1a51 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -158,6 +158,10 @@ struct SettingsView: View { /// sustained-elevated window and offers — via a single dismissible card — to save it as a workout. /// Nothing is ever created automatically. Mirrors the Android `NoopPrefs.KEY_AUTO_DETECT_WORKOUTS`. @AppStorage(PuffinExperiment.autoDetectWorkoutsKey) private var autoDetectWorkoutsEnabled = false + #if os(iOS) + /// Buzz the strap while an incoming call rings (`CallBuzzMonitor`). Default off. + @AppStorage(CallBuzzMonitor.enabledKey) private var callBuzzEnabled = false + #endif /// "Journal reminder" (#627, default ON). When ON, Today shows the persistent journal widget /// (last-7-days strip + tap-through). Mirrors the Android `NoopPrefs.KEY_JOURNAL_REMINDER_ENABLED`. @@ -1716,6 +1720,25 @@ struct SettingsView: View { .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) + #if os(iOS) + rowDivider + Toggle(isOn: $callBuzzEnabled) { + Text("Buzz strap for incoming calls") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + .accessibilityHint("Buzzes your strap while a call is ringing") + Text("Buzzes your strap while a call rings: phone calls, FaceTime, and apps that ring like a phone call, such as WhatsApp voice and video. Keeps buzzing until you answer or decline, or the call ends. NOOP only sees that a call is ringing, never who is calling.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + Text("iOS only lets NOOP react while it is running. With the app in the background it relies on the strap waking it, so turn on Continuous HRV capture (and turn off Overnight only) for alerts all day. Does not work after NOOP is swiped away in the app switcher.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + #endif rowDivider diff --git a/StrandiOS/App/CallBuzzMonitor.swift b/StrandiOS/App/CallBuzzMonitor.swift new file mode 100644 index 0000000000..b356057b22 --- /dev/null +++ b/StrandiOS/App/CallBuzzMonitor.swift @@ -0,0 +1,141 @@ +#if os(iOS) +import CallKit +import Foundation +import UIKit + +/// Wrist alert for incoming calls: buzzes the strap for as long as a call rings, like an Apple Watch, +/// and stops once it is answered, declined or ends. iOS counterpart of Android's `CallAlertController` +/// (#66), which instead stops after 4 buzzes. Same privacy line: NOOP only learns THAT a call is ringing, +/// never who is calling or from which app. +/// +/// Detection is `CXCallObserver`, which sees every CallKit call on the phone: regular phone calls, +/// FaceTime, and VoIP apps that ring through CallKit (WhatsApp audio and video, Messenger, Teams, …). +/// It needs no permission or entitlement. +/// +/// The limit is iOS itself: call changes are only delivered while NOOP is running. In the background +/// the app runs only when the strap wakes it with a Bluetooth frame, so `poll()` re-checks the call list +/// on inbound frames. With Continuous HRV capture on, the strap sends a frame every second and alerts +/// arrive within about a second; without it, frames are sparse and an alert can be missed. Nothing can +/// help once the app has been force-quit from the app switcher: iOS does not relaunch it. +@MainActor +final class CallBuzzMonitor: NSObject, CXCallObserverDelegate { + static let enabledKey = "notif.calls.enabled" + + /// One burst (3 pulses) plays for about 2 s on the strap (strap log: "Sequence fired" → "SEQ done"), + /// so a new burst every 3 s keeps the wrist buzzing with a short pause between bursts. + static let repeatInterval: TimeInterval = 3 + /// Self-heal ceiling (Android `MAX_RING_WINDOW_MS`): a cycle never runs longer than this even if + /// the call's end is never observed. Longer than any normal ring before voicemail. + static let maxRingWindow: TimeInterval = 90 + + static var isEnabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) } + + private let observer = CXCallObserver() + /// Fires one buzz; returns false when the strap can't be reached (not connected). + private let buzz: () -> Bool + private let log: (String) -> Void + + private var cycleCallID: UUID? + private var cycleStartedAt: Date? + private var buzzCount = 0 + private var lastBuzzAt: Date? + private var repeatWork: DispatchWorkItem? + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + private var lastPollAt = Date.distantPast + + init(buzz: @escaping () -> Bool, log: @escaping (String) -> Void) { + self.buzz = buzz + self.log = log + super.init() + observer.setDelegate(self, queue: .main) + } + + nonisolated func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) { + // Delivered on the main queue (see `setDelegate`), so this is already main-actor isolated. + MainActor.assumeIsolated { evaluate() } + } + + /// Re-check the call list, throttled to once a second. Called on every inbound strap frame, which + /// is what catches a call that started while the app was suspended. + func poll() { + let now = Date() + guard now.timeIntervalSince(lastPollAt) >= 1 else { return } + lastPollAt = now + evaluate() + } + + func evaluate() { + guard Self.isEnabled, let call = ringingCall() else { + stopCycle() + return + } + if call.uuid != cycleCallID { + startCycle(for: call.uuid) + } else { + tick() + } + } + + /// An incoming call that is still ringing: not placed by us, not answered, not ended. + private func ringingCall() -> CXCall? { + observer.calls.first { !$0.isOutgoing && !$0.hasConnected && !$0.hasEnded } + } + + private func startCycle(for id: UUID) { + stopCycle() + cycleCallID = id + cycleStartedAt = Date() + // Ask for background time so the repeat buzzes still fire if iOS would otherwise suspend us + // mid-ring (iOS grants roughly 30 s; after that the strap's frames keep driving `poll()`). + backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "CallBuzz") { [weak self] in + MainActor.assumeIsolated { self?.endBackgroundTask() } + } + log("Call alert: incoming call ringing") + tick() + } + + private func tick() { + repeatWork?.cancel() + repeatWork = nil + let now = Date() + let expired = cycleStartedAt.map { now.timeIntervalSince($0) >= Self.maxRingWindow } ?? true + // Past the ceiling: stop buzzing. `cycleCallID` is kept so the same call can't restart the cycle. + guard !expired else { + endBackgroundTask() + return + } + let due = lastBuzzAt.map { now.timeIntervalSince($0) >= Self.repeatInterval - 0.25 } ?? true + if due { + if buzz() { + buzzCount += 1 + lastBuzzAt = now + if buzzCount == 1 { log("Call alert: buzzing") } + } else if lastBuzzAt == nil { + log("Call alert: strap not connected, will retry") + } + } + let wait = lastBuzzAt.map { max(0.25, Self.repeatInterval - now.timeIntervalSince($0)) } ?? 1 + let work = DispatchWorkItem { [weak self] in self?.evaluate() } + repeatWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + wait, execute: work) + } + + private func stopCycle() { + guard cycleCallID != nil else { return } + log("Call alert: stopped after \(buzzCount) buzz\(buzzCount == 1 ? "" : "es")") + repeatWork?.cancel() + repeatWork = nil + cycleCallID = nil + cycleStartedAt = nil + buzzCount = 0 + lastBuzzAt = nil + endBackgroundTask() + } + + private func endBackgroundTask() { + guard backgroundTask != .invalid else { return } + UIApplication.shared.endBackgroundTask(backgroundTask) + backgroundTask = .invalid + } +} +#endif From 6ee3c3299d155af6f45bbbd0a3d244dde9d1d4c2 Mon Sep 17 00:00:00 2001 From: ahmadssadiq Date: Mon, 19 Oct 2026 16:24:13 -0400 Subject: [PATCH 3/4] chore(ios): sync Info.plist with project.yml (stalebattery task id) `xcodegen generate` adds $(PRODUCT_BUNDLE_IDENTIFIER).stalebattery to BGTaskSchedulerPermittedIdentifiers, because project.yml already declares it, but the committed Info.plist didn't have it. Committing the generated result so a fresh generate leaves the tree clean. Co-Authored-By: Claude Opus 5.5 --- StrandiOS/Resources/Info.plist | 1 + 1 file changed, 1 insertion(+) diff --git a/StrandiOS/Resources/Info.plist b/StrandiOS/Resources/Info.plist index 10b0a7681c..2d6441b6ea 100644 --- a/StrandiOS/Resources/Info.plist +++ b/StrandiOS/Resources/Info.plist @@ -10,6 +10,7 @@ $(PRODUCT_BUNDLE_IDENTIFIER).coachbrief $(PRODUCT_BUNDLE_IDENTIFIER).healthwriteback $(PRODUCT_BUNDLE_IDENTIFIER).rescore + $(PRODUCT_BUNDLE_IDENTIFIER).stalebattery CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) From 37ad9e2af92f19c99266f2d181e5527d37973f2e Mon Sep 17 00:00:00 2001 From: ahmadssadiq Date: Tue, 20 Oct 2026 15:36:23 -0400 Subject: [PATCH 4/4] feat(ios): double-tap the strap to silence an incoming-call buzz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the strap is buzzing for a ringing call, a strap double-tap stops the buzzing for that call (the call itself keeps ringing on the phone). A double-tap with no call buzzing does whatever it normally does. - CallBuzzMonitor.dismissFromStrap(): marks the current call as silenced (no more bursts for that call UUID even if it keeps ringing; cleared when it stops ringing) and returns true so the tap is consumed. - AppModel.handleDoubleTap: the call alert claims the double-tap first, ahead of the 1.2 s debounce, a Lift Log override and the user's configured double-tap action. Making one double-tap enough: on hardware the first double-tap seemed to need a second try. Decoding the strap's event frames showed the strap had sensed it (DOUBLE_TAP, strap timestamp ~2 s into the ring), but the phone received it ~1.5 s late, because the first buzz's HAPTICS_FIRED event had kicked a strap-event history sync (~4 s offload). Meanwhile the next 3-pulse burst (~2 s) had already been sent and played on. Two fixes: - BLEManager.holdStrapEventSync: asked before a strap-pushed EVENT kicks a sync. AppModel points it at CallBuzzMonitor.isAlerting, so no offload starts while a call is buzzing. Held syncs aren't lost: the next trigger or the 15-min tick runs one. - Shorter bursts: 2 pulses (~1.3 s) every 2 s instead of 3 pulses every 3 s, so at most ~1.3 s of an in-flight burst plays on after a double-tap. Ending the call: researched, not possible. The tap also sends CallKit's CXEndCallAction for the ringing call and logs iOS's answer. On an iPhone 16 Pro (iOS 26) for a phone call, iOS refused with com.apple.CallKit.error.requesttransaction 1 (unknownCallProvider): an app can only end calls its own CXProvider reported, and no public API ends or declines another app's or a carrier call. The sanctioned accessory route is ANCS's negative action, which needs the strap's firmware to be the ANCS client. The request is kept (harmless, and the log line documents the outcome). Settings caption: "Double-tap the strap to stop the buzzing (the call itself keeps ringing on your phone)." Tested on an iPhone 16 Pro (iOS 26) + WHOOP 5/MG with phone calls: one double-tap now stops the buzzing straight away. Before the sync hold and shorter bursts it needed two. The strap log shows "Double-tap → stop call buzz" and the CallKit refusal above. Co-Authored-By: Claude Opus 5.5 --- Strand/App/AppModel.swift | 11 ++++++ Strand/BLE/BLEManager.swift | 17 +++++++-- Strand/Screens/SettingsView.swift | 2 +- StrandiOS/App/CallBuzzMonitor.swift | 57 ++++++++++++++++++++++++++--- 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 87548a497b..d1369a49b4 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -559,6 +559,8 @@ final class AppModel: ObservableObject { log: { [live] line in live.append(log: line) }) self.callBuzz = callBuzz ble.onInboundFrame = { [weak callBuzz] in callBuzz?.poll() } + // Keep the link free while a call buzzes, so a double-tap to silence it isn't queued behind a sync. + ble.holdStrapEventSync = { [weak callBuzz] in callBuzz?.isAlerting ?? false } #endif } @@ -1812,6 +1814,15 @@ final class AppModel: ObservableObject { private func handleDoubleTap() { let now = Date() + #if os(iOS) + // A buzzing incoming-call alert claims the double-tap ahead of everything else (debounce, Lift + // Log, the configured action): two taps silence it. A tap with no call buzzing falls through. + if callBuzz?.dismissFromStrap() == true { + lastDoubleTapAt = now + live.append(log: Self.stamped("Double-tap → stop call buzz")) + return + } + #endif let since = now.timeIntervalSince(lastDoubleTapAt) guard since > 1.2 else { // debounce repeats live.append(log: Self.stamped(String(format: "Double-tap ignored: %.1f s after the previous one (debounce 1.2 s)", since))) diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index d3798e1bb6..e5c15d56d4 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -580,6 +580,10 @@ public final class BLEManager: NSObject, ObservableObject { /// Fired on every inbound notification. Each one is a moment iOS lets a backgrounded app run, so /// the incoming-call alert uses it to re-check for a ringing call (`CallBuzzMonitor.poll`). var onInboundFrame: (() -> Void)? + /// Asked before a strap-pushed EVENT kicks a history sync; true holds that sync off. AppModel points + /// it at the incoming-call alert: a sync started by the alert's own HAPTICS_FIRED events delayed the + /// strap's DOUBLE_TAP by ~1.5 s on hardware. Held syncs aren't lost; the next trigger or tick runs one. + var holdStrapEventSync: (() -> Bool)? private var registryStore: DeviceRegistryStore? /// #716: true once the seeded "WHOOP" model has been stamped to the correct family. private var modelStamped = false @@ -1383,7 +1387,10 @@ public final class BLEManager: NSObject, ObservableObject { central = CBCentralManager(delegate: self, queue: .main) #endif // Strap-as-clock: an incoming EVENT packet kicks a rate-limited catch-up sync. - router.onSyncTrigger = { [weak self] in self?.requestSync(.strap) } + router.onSyncTrigger = { [weak self] in + guard let self, self.holdStrapEventSync?() != true else { return } + self.requestSync(.strap) + } router.onStrapSerial = { [weak self] serial in self?.noteHarvardSerial(serial) } // #1193 // #78 hole-4: a paused-for-bond-loop strap gets one bounded salvage attempt per app-foreground. installForegroundSalvageProbe() @@ -1595,7 +1602,10 @@ public final class BLEManager: NSObject, ObservableObject { central = CBCentralManager(delegate: self, queue: .main) #endif // Strap-as-clock: an incoming EVENT packet kicks a rate-limited catch-up sync. - router.onSyncTrigger = { [weak self] in self?.requestSync(.strap) } + router.onSyncTrigger = { [weak self] in + guard let self, self.holdStrapEventSync?() != true else { return } + self.requestSync(.strap) + } router.onStrapSerial = { [weak self] serial in self?.noteHarvardSerial(serial) } // #1193 // #78 hole-4: a paused-for-bond-loop strap gets one bounded salvage attempt per app-foreground. installForegroundSalvageProbe() @@ -5607,7 +5617,8 @@ public final class BLEManager: NSObject, ObservableObject { buzzStrapOnce() return } - send(.runHapticsPattern, payload: [2, 3, 0, 0, 0], writeType: .withResponse, maverickEffect: 48) + // 2 pulses (~1.3 s): short, so little of a burst already sent plays on after a double-tap. + send(.runHapticsPattern, payload: [2, 2, 0, 0, 0], writeType: .withResponse, maverickEffect: 48) } /// Haptic Clock (#460): buzz the current wall-clock time out on the strap so the user can read it diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index d3384e1a51..403596ea94 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -1730,7 +1730,7 @@ struct SettingsView: View { .toggleStyle(.switch) .tint(StrandPalette.accent) .accessibilityHint("Buzzes your strap while a call is ringing") - Text("Buzzes your strap while a call rings: phone calls, FaceTime, and apps that ring like a phone call, such as WhatsApp voice and video. Keeps buzzing until you answer or decline, or the call ends. NOOP only sees that a call is ringing, never who is calling.") + Text("Buzzes your strap while a call rings: phone calls, FaceTime, and apps that ring like a phone call, such as WhatsApp voice and video. Keeps buzzing until you answer or decline, or the call ends. Double-tap the strap to stop the buzzing (the call itself keeps ringing on your phone). NOOP only sees that a call is ringing, never who is calling.") .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) diff --git a/StrandiOS/App/CallBuzzMonitor.swift b/StrandiOS/App/CallBuzzMonitor.swift index b356057b22..14ec392966 100644 --- a/StrandiOS/App/CallBuzzMonitor.swift +++ b/StrandiOS/App/CallBuzzMonitor.swift @@ -21,9 +21,10 @@ import UIKit final class CallBuzzMonitor: NSObject, CXCallObserverDelegate { static let enabledKey = "notif.calls.enabled" - /// One burst (3 pulses) plays for about 2 s on the strap (strap log: "Sequence fired" → "SEQ done"), - /// so a new burst every 3 s keeps the wrist buzzing with a short pause between bursts. - static let repeatInterval: TimeInterval = 3 + /// One burst is 2 pulses, about 1.3 s on the strap (a 3-pulse burst measured ~2 s, "Sequence fired" → + /// "SEQ done"), so a new burst every 2 s keeps the wrist buzzing with a short pause between bursts. + /// Kept short because a burst already sent plays out after a double-tap silences the alert. + static let repeatInterval: TimeInterval = 2 /// Self-heal ceiling (Android `MAX_RING_WINDOW_MS`): a cycle never runs longer than this even if /// the call's end is never observed. Longer than any normal ring before voicemail. static let maxRingWindow: TimeInterval = 90 @@ -31,11 +32,15 @@ final class CallBuzzMonitor: NSObject, CXCallObserverDelegate { static var isEnabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) } private let observer = CXCallObserver() + private let callController = CXCallController() /// Fires one buzz; returns false when the strap can't be reached (not connected). private let buzz: () -> Bool private let log: (String) -> Void private var cycleCallID: UUID? + /// Set by a strap double-tap (`dismissFromStrap`): no more buzzes for `cycleCallID`, even though it + /// may keep ringing. Cleared when the call stops ringing. + private var dismissed = false private var cycleStartedAt: Date? private var buzzCount = 0 private var lastBuzzAt: Date? @@ -76,6 +81,44 @@ final class CallBuzzMonitor: NSObject, CXCallObserverDelegate { } } + /// True while the strap is being buzzed for a ringing call (not silenced, not past the ceiling). + /// BLEManager holds off strap-event-triggered history syncs meanwhile: each buzz makes the strap + /// report HAPTICS_FIRED, which would start a multi-second offload, and on hardware a DOUBLE_TAP sensed + /// during that offload reached the phone ~1.5 s late, so silencing seemed to need a second try. + var isAlerting: Bool { + guard cycleCallID != nil, !dismissed, let start = cycleStartedAt else { return false } + return Date().timeIntervalSince(start) < Self.maxRingWindow + } + + /// Strap double-tap while a call is buzzing: stop buzzing for this call and return true (the tap is + /// consumed). Returns false when nothing is buzzing, so the tap does whatever it normally does. + /// + /// It also asks CallKit to end the call. iOS only performs CXEndCallAction for calls the requesting + /// app's own CXProvider reported, so for phone, FaceTime and WhatsApp calls it is expected to refuse + /// (no public API ends or declines another app's call; the sanctioned accessory route is ANCS, + /// which needs the strap's firmware to be the ANCS client). Confirmed on an iPhone 16 Pro (iOS 26) + /// for a phone call: com.apple.CallKit.error.requesttransaction 1 (unknownCallProvider). The + /// outcome is still logged, so the strap log records exactly what iOS answered. + func dismissFromStrap() -> Bool { + guard let id = cycleCallID, !dismissed, ringingCall()?.uuid == id else { return false } + dismissed = true + repeatWork?.cancel() + repeatWork = nil + log("Call alert: stopped by double-tap after \(buzzCount) buzz\(buzzCount == 1 ? "" : "es")") + callController.request(CXTransaction(action: CXEndCallAction(call: id))) { [weak self] error in + DispatchQueue.main.async { + guard let self else { return } + if let error = error as NSError? { + self.log("Call alert: iOS declined to end the call (\(error.domain) \(error.code): \(error.localizedDescription))") + } else { + self.log("Call alert: call ended from the strap") + } + self.endBackgroundTask() + } + } + return true + } + /// An incoming call that is still ringing: not placed by us, not answered, not ended. private func ringingCall() -> CXCall? { observer.calls.first { !$0.isOutgoing && !$0.hasConnected && !$0.hasEnded } @@ -99,8 +142,9 @@ final class CallBuzzMonitor: NSObject, CXCallObserverDelegate { repeatWork = nil let now = Date() let expired = cycleStartedAt.map { now.timeIntervalSince($0) >= Self.maxRingWindow } ?? true - // Past the ceiling: stop buzzing. `cycleCallID` is kept so the same call can't restart the cycle. - guard !expired else { + // Silenced by a double-tap, or past the ceiling: stop buzzing. `cycleCallID` is kept so the same + // call can't restart the cycle. + guard !dismissed, !expired else { endBackgroundTask() return } @@ -122,10 +166,11 @@ final class CallBuzzMonitor: NSObject, CXCallObserverDelegate { private func stopCycle() { guard cycleCallID != nil else { return } - log("Call alert: stopped after \(buzzCount) buzz\(buzzCount == 1 ? "" : "es")") + if !dismissed { log("Call alert: stopped after \(buzzCount) buzz\(buzzCount == 1 ? "" : "es")") } repeatWork?.cancel() repeatWork = nil cycleCallID = nil + dismissed = false cycleStartedAt = nil buzzCount = 0 lastBuzzAt = nil