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/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" diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index b477b8b992..d1369a49b4 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,22 @@ 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() } + // 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 } /// Build the device registry + source coordinator once the store is open, then start observing. @@ -1794,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/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 690e394194..21d7bd7144 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -577,6 +577,13 @@ 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)? + /// 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 @@ -1327,6 +1334,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, @@ -1380,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() @@ -1592,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() @@ -1652,6 +1667,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. @@ -2086,6 +2102,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 @@ -2098,6 +2127,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 @@ -2153,6 +2183,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 @@ -2279,7 +2310,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 +2458,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) @@ -5094,6 +5126,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 @@ -5592,6 +5625,21 @@ 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 + } + // 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 /// 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 @@ -5939,6 +5987,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") @@ -7313,6 +7362,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/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/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/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) { diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 48e3a5e8df..403596ea94 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. 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) + 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/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. /// diff --git a/StrandiOS/App/CallBuzzMonitor.swift b/StrandiOS/App/CallBuzzMonitor.swift new file mode 100644 index 0000000000..14ec392966 --- /dev/null +++ b/StrandiOS/App/CallBuzzMonitor.swift @@ -0,0 +1,186 @@ +#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 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 + + 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? + 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() + } + } + + /// 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 } + } + + 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 + // 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 + } + 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 } + 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 + endBackgroundTask() + } + + private func endBackgroundTask() { + guard backgroundTask != .invalid else { return } + UIApplication.shared.endBackgroundTask(backgroundTask) + backgroundTask = .invalid + } +} +#endif 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) 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.