From 08b1fc67a4d51ef6e1ce9575ea91946c85957a46 Mon Sep 17 00:00:00 2001 From: ahmadssadiq Date: Mon, 19 Oct 2026 16:23:41 -0400 Subject: [PATCH 1/7] 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 be208c16b48de163d2a670132d4392a2e76d18e8 Mon Sep 17 00:00:00 2001 From: ahmadssadiq Date: Mon, 19 Oct 2026 16:24:02 -0400 Subject: [PATCH 2/7] 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 32c7a719a651a6d83c073b5e6d4b17845c645be4 Mon Sep 17 00:00:00 2001 From: ahmadssadiq Date: Mon, 19 Oct 2026 16:24:13 -0400 Subject: [PATCH 3/7] 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 46e008cb0ecf06340fafe2fed5a00369790b1412 Mon Sep 17 00:00:00 2001 From: ahmadssadiq Date: Mon, 19 Oct 2026 16:24:13 -0400 Subject: [PATCH 4/7] local: list every workout and hide Apple Health workouts Personal preference for this fork, NOT intended for upstream. With a WHOOP CSV export imported and Apple Health connected, every WHOOP workout also exists as an identical Apple Health copy (the WHOOP app writes its workouts into Health). Cross-source dedup only collapses pairs with the same sport name, so pairs like WHOOP "Padel" vs Health "Workout", or "Weightlifting" vs "Strength Training", showed twice. - Repository.workoutNamespaces: drop apple-health, so Apple Health workouts no longer appear in Workouts or on Today. Apple Health still feeds steps, weight and calories. Side effect: auto-detect no longer treats Apple Health workouts as already saved. - WorkoutsView.currentScopeCount = Int.max: the "Current" tab shows every session instead of the 10 most recent. A proper upstream fix would be a setting, or collapsing time-identical WHOOP/Apple pairs regardless of sport name. Co-Authored-By: Claude Opus 5.5 --- Strand/Data/Repository.swift | 3 ++- Strand/Screens/WorkoutsView.swift | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 3b05714075..2f0d1ca240 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -296,7 +296,8 @@ final class Repository: ObservableObject { /// disagree, and a future namespace added to the read alone would reintroduce exactly this bug. nonisolated static func workoutNamespaces(rawIds: [String]) -> [String] { deletableWorkoutNamespaces(rawIds: rawIds) - + [WorkoutSource.appleHealthSource, "lifting", "activity-file"] + // Local patch: Apple Health workouts left out (they are WHOOP's own copies synced into Health). + + ["lifting", "activity-file"] } /// The subset of [workoutNamespaces] a DELETE may touch: the strap namespaces only. diff --git a/Strand/Screens/WorkoutsView.swift b/Strand/Screens/WorkoutsView.swift index 40baf31dcf..9ddb8ada82 100644 --- a/Strand/Screens/WorkoutsView.swift +++ b/Strand/Screens/WorkoutsView.swift @@ -663,7 +663,7 @@ struct WorkoutsView: View { } /// How many of the most recent sessions "Current" holds. - static let currentScopeCount = 10 + static let currentScopeCount = Int.max // Local patch: show every session in "Current" (was 10). /// Split rows into the most recent `currentCount` and everything older. /// From e62a789e3720b4fb6a2365f7bb97aa4d720bb992 Mon Sep 17 00:00:00 2001 From: ahmadssadiq Date: Mon, 19 Oct 2026 16:24:13 -0400 Subject: [PATCH 5/7] local: build without the embedded watch app Personal build setting for this fork, NOT intended for upstream. The NOOPiOS scheme embeds NOOPWatch, and xcodebuild refuses to build it unless the watchOS SDK is installed ("watchOS 26.0 must be installed"). Commented out the NOOPWatch dependency of NOOPiOS so the iPhone app builds on a Mac with only the iOS platform. Restore the line (and run `xcodebuild -downloadPlatform watchOS`) to ship the watch app again. Co-Authored-By: Claude Opus 5.5 --- project.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project.yml b/project.yml index f892b312b1..df69f7ab31 100644 --- a/project.yml +++ b/project.yml @@ -368,7 +368,7 @@ targets: # installs by riding inside its iOS companion; without this it would never reach a paired watch. # Build-from-source delivers it for certain; sideload install of the embedded watch app under a free # Apple ID is unproven (an Apple limitation), so the docs steer watch users to a source build for now. - - target: NOOPWatch + # - target: NOOPWatch # local: skipped (no watchOS SDK) # WidgetKit + Live Activity extension — the iOS analogue of the macOS menu-bar # extra. Reads a tiny snapshot the app publishes into the shared App Group. From e79c65591181ee59d8ab9e524ca1539f1ccb1f63 Mon Sep 17 00:00:00 2001 From: ahmadssadiq Date: Mon, 19 Oct 2026 19:33:59 -0400 Subject: [PATCH 6/7] feat(macos): mirror mode, a read-only copy of the iPhone's data Lets the Mac app show the iPhone's NOOP data without ever connecting to the strap. Why: a WHOOP holds an encrypted bond with one device at a time, and it deletes history once a device has offloaded it. A Mac that also connects takes the bond from the phone (no buzz, alarm or history sync there) and splits the history between two stores that never merge. Mirror mode keeps the phone as the only device on the strap, and gives the Mac the phone's data through the existing Backup & Sync folder flow: 1. The iPhone backs up to a folder (iCloud Drive), daily or on "Back up now" (unchanged). 2. On every Mac launch, before the store opens, the Mac restores the newest .noopbak in that folder if it hasn't loaded it yet. 3. While mirroring, every Bluetooth scan/connect path refuses, and the Mac never writes its own backups into the folder. What changed: - Strand/Data/MirrorMode.swift (new): the `mirror.enabled` toggle (always false on iOS, which is the source), the launch step `restoreNewestOnLaunch()`, and the status line shown in Backup & Sync ("Loaded the iPhone backup from ...", "Up to date with ...", "downloading from iCloud", "choose the folder", "no backups yet"). - FolderBackup.mirrorRestoreNewest(skipping:) (macOS): picks the newest snapshot via listSnapshots(), skips it when it matches the one already loaded (by name AND time, so a re-written hand-named file still counts as new), and restores through the same hardened DataBackup.restore core as a manual restore, with the size cap lifted. It deletes the pre-restore whoop-replaced-*.sqlite copy on success, so a launch-time restore doesn't leave a full-size copy every time. It doesn't wait on an undownloaded iCloud placeholder: it starts the download and loads it next launch, so launch is never held up by the network. - StrandApp.init calls the launch step before the @StateObject AppModel (and so the store) is created. The restore swaps the SQLite file underneath. - FolderBackup.backupNow refuses while mirroring (this covers the daily catch-up). A Mac snapshot would otherwise become the newest one it then mirrors, and its prune could delete the iPhone's snapshots. Backup & Sync greys out auto-backup and "Back up now" while the mirror is on. - BLEManager: new mirrorModeRefuses(_:) helper (logged once per launch), checked first in whoopConnectAllowed (standing connect, system connect, restored connect) and in the user-initiated paths that bypass that gate: connectCore, scanForWhoops, startScan, and the didDiscover connect. - SourceCoordinator.start() returns early while mirroring, so no non-WHOOP live source (standard HR, FTMS, Huami, Oura) starts for devices listed in the mirrored registry. - Backup & Sync (macOS): a "Mirror my iPhone" card with setup steps, the last launch's status (or "quit and reopen" when switched on mid-session) and a Quit button. The toggle needs a folder to turn on, but can always be turned off. The CBCentralManager is still created (BLEManager force-unwraps it in 43 places), so macOS may still ask for Bluetooth permission once. Answering "Don't Allow" is fine in mirror mode. Tested on macOS (MacBook, ad-hoc signed Release build) with a .noopbak made from a real iPhone database (53 WHOOP workouts, 27 sleeps): - First launch with mirror on: the Mac store went from 0 to 53 workouts, 27 sleeps and the journal. Status "Loaded the iPhone backup from ...", no whoop-replaced-* copy left behind, zero scan or connect lines, and one "Mirror mode: connect-from-system refused" log line. - Second launch: "Up to date with ...", no restore, no scanning. - For contrast, the same app's earlier launch, before this change and with no strap paired, logged 73 automatic WHOOP scans. - The iOS app (NOOPiOS Release) still builds and runs; mirror mode is a no-op there. Not yet tested: a backup arriving through a real iCloud Drive folder picked on both devices. Co-Authored-By: Claude Opus 5.5 --- Strand/App/StrandApp.swift | 3 ++ Strand/BLE/BLEManager.swift | 20 +++++++ Strand/BLE/SourceCoordinator.swift | 3 ++ Strand/Data/BackupSync.swift | 48 +++++++++++++++++ Strand/Data/MirrorMode.swift | 81 +++++++++++++++++++++++++++++ Strand/Screens/BackupSyncView.swift | 52 +++++++++++++++++- 6 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 Strand/Data/MirrorMode.swift diff --git a/Strand/App/StrandApp.swift b/Strand/App/StrandApp.swift index 588e83a2e4..2f8ffe627b 100644 --- a/Strand/App/StrandApp.swift +++ b/Strand/App/StrandApp.swift @@ -8,6 +8,9 @@ struct StrandApp: App { // #1008: pin the pre-change Overnight-only default for existing installs before // anything reads it. Idempotent; a no-op on fresh installs and after the first launch. PuffinExperiment.migrateContinuousHrvOvernightDefault() + // Mirror mode: swap in the iPhone's newest backup BEFORE `model` (and with it the store) is + // created below; the restore replaces the SQLite file. A no-op when mirror mode is off. + MirrorMode.restoreNewestOnLaunch() #if DEBUG // DEBUG-only promo-screenshot harness: when launched with `--demo-hour `, pin the Today // screen to that hour's day-cycle scene + a plausible per-hour stat frame. Runs synchronously diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index d3798e1bb6..7752bb366b 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1330,6 +1330,8 @@ public final class BLEManager: NSObject, ObservableObject { /// The last entry point `whoopConnectAllowed` turned away, so the block is logged once per path /// rather than on every tick of the family-rotation timer. private var lastBlockedConnectReason: String? + /// Mirror mode's refusal is logged once per launch (see `mirrorModeRefuses`). + private var mirrorRefusalLogged = false /// #1635: one `ScanAdvertisementSummary` line per SCAN, not per process — the question it answers /// is only visible by comparing a scan before the strap was put in pairing mode against one after, @@ -1655,6 +1657,7 @@ public final class BLEManager: NSObject, ObservableObject { } private func connectCore(model: WhoopModel) { + guard !mirrorModeRefuses("connect") else { return } intentionalDisconnect = false // Connection test mode: stamp when this connect attempt began so didConnect can report the connect // latency. A plain Date() assignment, no behaviour change; only read behind the .connection gate. @@ -2089,6 +2092,19 @@ public final class BLEManager: NSObject, ObservableObject { : "WHOOP is no longer the active device — not scanning or connecting until it is again (#1881)") } + /// Mirror mode (Mac) refuses every scan and connect: this app shows a copy of the iPhone's data and + /// must never take the strap's single bond away from the phone. Checked by `whoopConnectAllowed` and + /// by the user-initiated entry points that bypass it (`connectCore`, `scanForWhoops`, `startScan`, + /// the discovery connect). Logged once per launch. + private func mirrorModeRefuses(_ reason: String) -> Bool { + guard MirrorMode.isEnabled else { return false } + if !mirrorRefusalLogged { + mirrorRefusalLogged = true + log("Mirror mode: \(reason) refused. This Mac mirrors the iPhone's data and never connects to the strap.") + } + return true + } + /// The one gate every WHOOP scan/connect entry point passes through (#1881). /// /// Deliberately at the CONNECT sites rather than only at `connectCore`: there are five @@ -2101,6 +2117,7 @@ public final class BLEManager: NSObject, ObservableObject { /// says which path tried. Logged only on the transition into blocking, so a rotation timer cannot /// flood the log. private func whoopConnectAllowed(_ reason: String) -> Bool { + if mirrorModeRefuses(reason) { return false } if whoopIsActiveDevice { return true } // The flag is a CACHE of a registry fact, and the registry is the authority. Re-validate before // refusing, so the gate can never latch: it is set from the coordinator's stop/start closures and @@ -2156,6 +2173,7 @@ public final class BLEManager: NSObject, ObservableObject { /// The wizard MUST call `stopWhoopScan()` before any normal connect resumes — this mode owns the /// central while active. No-op-to-the-connect-path: it never touches `peripheral`/bond state. public func scanForWhoops() { + guard !mirrorModeRefuses("add-a-WHOOP scan") else { return } guard central.state == .poweredOn else { log("Add-a-WHOOP scan: Bluetooth not powered on (state=\(central.state.rawValue))") return @@ -5098,6 +5116,7 @@ public final class BLEManager: NSObject, ObservableObject { /// from the already-gated `connectFromSystem` — and this method's own family-rotation timer, which /// cannot start a scan that one of those did not. Gating here as well would block the user's Connect. private func startScan(for model: WhoopModel, allowFallback: Bool) { + guard !mirrorModeRefuses("scan") else { return } advertisementLogged = false cancelScanFallback() selectedModel = model @@ -5957,6 +5976,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { } // No gate here for the same reason as `startScan`: reaching a discovery means a scan is running, // and a scan only runs because the user asked or because the gated system entry allowed it. + guard !mirrorModeRefuses("discovery connect") else { central.stopScan(); return } cancelScanFallback() persistSelectedModel(selectedModel) log("Discovered \(safeName) (rssi \(RSSI)) — connecting") diff --git a/Strand/BLE/SourceCoordinator.swift b/Strand/BLE/SourceCoordinator.swift index fd296556bf..f219d3a88d 100644 --- a/Strand/BLE/SourceCoordinator.swift +++ b/Strand/BLE/SourceCoordinator.swift @@ -141,6 +141,9 @@ final class SourceCoordinator: ObservableObject { /// default preferred peripheral (nil) — no scan/disconnect churn. The connected-uuid sink drives /// first-connect identity adoption. func start() { + // Mirror mode (Mac): never start any live source. The mirrored registry lists the iPhone's + // devices, and connecting to them would take the strap's single bond away from the phone. + guard !MirrorMode.isEnabled else { return } registry.$activeDeviceId .removeDuplicates() .sink { [weak self] id in self?.activeDeviceChanged(to: id) } diff --git a/Strand/Data/BackupSync.swift b/Strand/Data/BackupSync.swift index 481ffcce1f..83342733e0 100644 --- a/Strand/Data/BackupSync.swift +++ b/Strand/Data/BackupSync.swift @@ -301,6 +301,9 @@ enum FolderBackup { /// Returns true on success. Runs the whole-DB ZIP off the caller's thread; never touches UI. @discardableResult static func backupNow(checkpoint: @escaping () async -> Bool) async -> Bool { + // A mirroring Mac never writes here: its snapshot would become the newest one it then mirrors, + // and the prune below could delete the iPhone's snapshots. (Covers the daily catch-up too.) + guard !MirrorMode.isEnabled else { return false } guard let folder = resolveFolder() else { return false } let scoped = folder.startAccessingSecurityScopedResource() defer { if scoped { folder.stopAccessingSecurityScopedResource() } } @@ -440,6 +443,51 @@ enum FolderBackup { return DataBackup.restore(from: source) } + #if os(macOS) + enum MirrorOutcome { + case restored(name: String, timeMs: Int) + case upToDate(timeMs: Int) + case downloading + case noFolder + case noBackups + case failed(String) + } + + /// Mirror mode's launch step (`MirrorMode.restoreNewestOnLaunch`): restore the newest backup in the + /// folder unless it is the one already loaded (`skipping`, matched by name AND time so a re-written + /// hand-named file still counts as new). Uses the same hardened `DataBackup.restore` core as a manual + /// restore, with the size cap lifted (the user opted in to whole-store replacement). The pre-restore + /// copy is deleted on success: a mirror's own data is disposable, and keeping one per launch would + /// pile up full-size copies. Does NOT wait on an undownloaded iCloud placeholder: it starts the + /// download and reports `.downloading`, so launch is never held up by the network. + static func mirrorRestoreNewest(skipping last: (name: String, timeMs: Int)?) -> MirrorOutcome { + guard let folder = resolveFolder() else { return .noFolder } + guard let newest = listSnapshots().first else { return .noBackups } + if let last, last.name == newest.name, last.timeMs == newest.timeMs { + return .upToDate(timeMs: newest.timeMs) + } + let scoped = folder.startAccessingSecurityScopedResource() + defer { if scoped { folder.stopAccessingSecurityScopedResource() } } + let source = folder.appendingPathComponent(newest.name) + guard FileManager.default.fileExists(atPath: source.path) else { + let placeholder = folder.appendingPathComponent("." + newest.name + ".icloud") + try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder) + return .downloading + } + let dbPath: String + do { dbPath = try StorePaths.defaultDatabasePath() } catch { return .failed(error.localizedDescription) } + switch DataBackup.restore(from: source, toDatabaseAt: dbPath, allowOversize: true) { + case .imported(let sidecar): + if sidecar.path != dbPath { try? FileManager.default.removeItem(at: sidecar) } + return .restored(name: newest.name, timeMs: newest.timeMs) + case .failure(let message): + return .failed(message) + default: + return .failed(String(localized: "The backup couldn't be read.")) + } + } + #endif + // MARK: - Folder picker #if os(macOS) diff --git a/Strand/Data/MirrorMode.swift b/Strand/Data/MirrorMode.swift new file mode 100644 index 0000000000..4f2d2886b8 --- /dev/null +++ b/Strand/Data/MirrorMode.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Mirror mode (macOS): this Mac shows a read-only copy of the iPhone's NOOP data instead of talking to +/// the strap itself. +/// +/// Why: a WHOOP holds an encrypted bond with one device at a time, and the strap forgets history once a +/// device has offloaded it. A Mac that also connects steals the bond from the phone (no buzz, alarm or +/// sync there) and splits the history between two stores that never merge. Mirror mode keeps the phone +/// as the only device on the strap and gives the Mac the phone's data through Backup & Sync: +/// +/// 1. The iPhone backs up to a folder (iCloud Drive), daily or on "Back up now". +/// 2. On every launch, BEFORE the store opens, this Mac restores the newest backup in that folder if it +/// hasn't loaded it yet (`restoreNewestOnLaunch`, called from `StrandApp.init`). +/// 3. Every Bluetooth scan / connect path refuses while it is on (`BLEManager`, `SourceCoordinator`), +/// and this Mac never writes its own backups into the folder (it would then mirror itself). +/// +/// Anything changed on the Mac is replaced by the next restore. iOS is always the source: `isEnabled` is +/// false there whatever the stored value. +enum MirrorMode { + static let enabledKey = "mirror.enabled" + private static let lastNameKey = "mirror.lastSnapshotName" + private static let lastTimeKey = "mirror.lastSnapshotTimeMs" + private static let statusKey = "mirror.lastStatus" + private static let checkedAtKey = "mirror.lastCheckedAt" + + static var isEnabled: Bool { + #if os(macOS) + return UserDefaults.standard.bool(forKey: enabledKey) + #else + return false + #endif + } + + /// What the last launch did, for the Backup & Sync screen ("Loaded the iPhone backup from …"). + static var lastStatus: String? { UserDefaults.standard.string(forKey: statusKey) } + /// When the last launch check ran (epoch seconds), 0 if never. + static var lastCheckedAt: Double { UserDefaults.standard.double(forKey: checkedAtKey) } + /// Whether this launch ran the mirror restore, so a toggle switched on mid-session can say a relaunch + /// is needed instead of showing an older launch's status. + private(set) static var ranThisLaunch = false + + /// Load the newest backup from the Backup & Sync folder if it is new. Must run before anything opens + /// the store: the restore swaps the SQLite file underneath. Synchronous on purpose (it is launch-time, + /// and the app can't show data until it's done); an iCloud file that isn't downloaded yet is NOT + /// waited for: its download is started and the next launch picks it up. + static func restoreNewestOnLaunch() { + #if os(macOS) + guard isEnabled else { return } + ranThisLaunch = true + let d = UserDefaults.standard + let last = d.string(forKey: lastNameKey).map { (name: $0, timeMs: d.integer(forKey: lastTimeKey)) } + let status: String + switch FolderBackup.mirrorRestoreNewest(skipping: last) { + case .restored(let name, let timeMs): + d.set(name, forKey: lastNameKey) + d.set(timeMs, forKey: lastTimeKey) + status = String(localized: "Loaded the iPhone backup from \(stamp(timeMs)).") + case .upToDate(let timeMs): + status = String(localized: "Up to date with the iPhone backup from \(stamp(timeMs)).") + case .downloading: + status = String(localized: "A newer iPhone backup is downloading from iCloud. Reopen NOOP in a minute to load it.") + case .noFolder: + status = String(localized: "Choose the same backup folder the iPhone uses to start mirroring.") + case .noBackups: + status = String(localized: "No backups in the folder yet. On the iPhone, open Backup & Sync and tap Back up now.") + case .failed(let message): + status = String(localized: "Couldn't load the newest backup: \(message)") + } + d.set(status, forKey: statusKey) + d.set(Date().timeIntervalSince1970, forKey: checkedAtKey) + #endif + } + + private static func stamp(_ timeMs: Int) -> String { + guard timeMs > 0 else { return String(localized: "an unknown time") } + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + return f.string(from: Date(timeIntervalSince1970: Double(timeMs) / 1000)) + } +} diff --git a/Strand/Screens/BackupSyncView.swift b/Strand/Screens/BackupSyncView.swift index f6971de330..c2b7fed5dd 100644 --- a/Strand/Screens/BackupSyncView.swift +++ b/Strand/Screens/BackupSyncView.swift @@ -1,5 +1,8 @@ import SwiftUI import StrandDesign +#if os(macOS) +import AppKit +#endif /// Backup & Sync (folder destination). The Apple mirror of the Android `BackupSyncScreen`: pick a /// folder, turn on daily auto-backup (an on-launch catch-up), back up now, or restore from a snapshot @@ -13,6 +16,8 @@ struct BackupSyncView: View { @State private var lastMs = FolderBackup.lastBackupMs @State private var keep = FolderBackup.keepCount @State private var busy = false + /// Mirror mode (macOS only; always false on iOS). While on, this device never backs up to the folder. + @State private var mirror = MirrorMode.isEnabled // Result alert (backup outcome / restore outcome). @State private var alertTitle = "" @@ -33,6 +38,9 @@ struct BackupSyncView: View { ) { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { folderCard + #if os(macOS) + mirrorCard + #endif autoCard restoreCard } @@ -117,7 +125,7 @@ struct BackupSyncView: View { Spacer(minLength: 0) Toggle("Daily auto-backup", isOn: $auto) .labelsHidden().toggleStyle(.switch).tint(StrandPalette.accent) - .disabled(folderLabel == nil) + .disabled(folderLabel == nil || mirror) .onChangeCompat(of: auto) { on in FolderBackup.autoEnabled = on } } // Retention: how many dated snapshots to keep. Wired to FolderBackup.keepCount; the next @@ -159,10 +167,50 @@ struct BackupSyncView: View { } NoopButton(busy ? "Working…" : "Back up now", systemImage: "icloud.and.arrow.up", kind: .primary, fullWidth: true) { backupNow() } - .disabled(folderLabel == nil || busy) + .disabled(folderLabel == nil || busy || mirror) + } + } + } + + #if os(macOS) + /// Mirror mode: this Mac loads the iPhone's newest backup at every launch and never touches the strap. + private var mirrorCard: some View { + StrandCard(padding: 20, tint: mirror ? StrandPalette.accent : nil) { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("Mirror my iPhone") + .font(StrandFont.body).foregroundStyle(StrandPalette.textPrimary) + Text("Each time NOOP opens, it loads the newest iPhone backup from this folder and never connects to your strap, so the strap stays with your iPhone. Changes made on this Mac are replaced by the next backup, and this Mac doesn't back up while mirroring.") + .font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + Toggle("Mirror my iPhone", isOn: $mirror) + .labelsHidden().toggleStyle(.switch).tint(StrandPalette.accent) + .disabled(folderLabel == nil && !mirror) // needs a folder to turn on, never to turn off + .onChangeCompat(of: mirror) { on in + UserDefaults.standard.set(on, forKey: MirrorMode.enabledKey) + if on { auto = false; FolderBackup.autoEnabled = false } + } + } + Text("On your iPhone: Backup & Sync → choose a folder in iCloud Drive → turn on Daily auto-backup. On this Mac: choose the same folder above.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + if mirror { + Text(MirrorMode.ranThisLaunch + ? (MirrorMode.lastStatus ?? String(localized: "Mirroring.")) + : String(localized: "Quit and reopen NOOP to load the newest iPhone backup.")) + .font(StrandFont.caption).foregroundStyle(StrandPalette.accent) + .fixedSize(horizontal: false, vertical: true) + NoopButton("Quit NOOP", systemImage: "power", kind: .secondary) { + NSApplication.shared.terminate(nil) + } + } } } } + #endif private var restoreCard: some View { StrandCard(padding: 20) { From 392a69e1440b91a08a788bbe04562e88054b0f8b Mon Sep 17 00:00:00 2001 From: ahmadssadiq Date: Tue, 20 Oct 2026 15:36:23 -0400 Subject: [PATCH 7/7] 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 7752bb366b..21d7bd7144 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 @@ -1385,7 +1389,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() @@ -1597,7 +1604,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() @@ -5626,7 +5636,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