Skip to content

Ios call buzz and coach fix - #2813

Open
ahmadssadiq wants to merge 7 commits into
ryanbr:mainfrom
ahmadssadiq:ios-call-buzz-and-coach-fix
Open

ahmadssadiq wants to merge 7 commits into
ryanbr:mainfrom
ahmadssadiq:ios-call-buzz-and-coach-fix

Conversation

@ahmadssadiq

Copy link
Copy Markdown

What this PR does

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

Checklist

  • Swift package tests pass for any package I touched (swift test in Packages/<name>)
  • Android unit tests pass if I touched android/ (./gradlew testFullDebugUnitTest; after a branch switch add --no-build-cache --rerun-tasks, or Gradle may fail classes you never touched)
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens — no hardcoded colors, fonts, or spacing
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

ahmadssadiq and others added 7 commits October 19, 2026 16:23
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 <noreply@anthropic.com>
iOS counterpart of the Android incoming-call wrist alert (ryanbr#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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant