From 1d681061890ea0518d6fd7f84491f22a27625e1c Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 12 Sep 2026 18:04:28 +0200 Subject: [PATCH 1/9] feat(ios): add background band sync Shortcuts Route Sync Data through a shared headless-capable Flutter engine and the existing band ownership and commit-before-ACK persistence path. Add opt-in connectivity-error suppression, an interactive foreground fallback, and Dart/native/system-invocation regression tests. --- README.md | 3 +- guides/IOS_SHORTCUTS.md | 61 ++++ ios/OpenStrapIntents.swift | 61 +++- ios/Runner.xcodeproj/project.pbxproj | 172 ++++++++++- .../xcschemes/ShortcutIntents.xcscheme | 128 ++++++++ ios/Runner/AppDelegate.swift | 42 ++- ios/Runner/Info.plist | 4 - ios/Runner/SceneDelegate.swift | 12 + ios/Runner/ShortcutSyncBridge.swift | 160 ++++++++++ ios/RunnerTests/RunnerTests.swift | 161 +++++++++- ios/ShortcutUITests/Info.plist | 8 + ios/ShortcutUITests/ShortcutUITests.swift | 80 +++++ lib/main.dart | 3 + lib/state/app_state.dart | 88 ++++-- lib/sync/background_sync.dart | 186 ++++++------ lib/sync/ios_shortcut_sync.dart | 280 ++++++++++++++++++ lib/sync/shortcut_sync_task.dart | 69 +++++ test/app_state_shortcut_sync_test.dart | 175 +++++++++++ test/ios_shortcut_sync_test.dart | 142 +++++++++ test/reset_quiesces_ingest_test.dart | 2 +- test/shortcut_sync_task_test.dart | 96 ++++++ 21 files changed, 1788 insertions(+), 145 deletions(-) create mode 100644 guides/IOS_SHORTCUTS.md create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/ShortcutIntents.xcscheme create mode 100644 ios/Runner/ShortcutSyncBridge.swift create mode 100644 ios/ShortcutUITests/Info.plist create mode 100644 ios/ShortcutUITests/ShortcutUITests.swift create mode 100644 lib/sync/ios_shortcut_sync.dart create mode 100644 lib/sync/shortcut_sync_task.dart create mode 100644 test/app_state_shortcut_sync_test.dart create mode 100644 test/ios_shortcut_sync_test.dart create mode 100644 test/shortcut_sync_task_test.dart diff --git a/README.md b/README.md index 9b20571bc..d91c49bd8 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,8 @@ drawer-bracelet problem can use it, or go dig through the code themselves. |
**Recap** |
**Profile** | | iOS also gets a home-screen widget, a lock-screen/Dynamic Island Live Activity, and a -couple of Siri shortcuts. +couple of Siri shortcuts, including [Sync Data](guides/IOS_SHORTCUTS.md) for +on-demand or scheduled band synchronization without opening the interface. | | | | |:--:|:--:|:--:| diff --git a/guides/IOS_SHORTCUTS.md b/guides/IOS_SHORTCUTS.md new file mode 100644 index 000000000..8f152af87 --- /dev/null +++ b/guides/IOS_SHORTCUTS.md @@ -0,0 +1,61 @@ +# Syncing with iOS Shortcuts + +## Sync Data + +On iOS 16 and later, add **Edge → Sync Data** to a shortcut. Pair your band in Edge and grant Bluetooth access before using an unattended automation. The action runs without opening Edge's interface. It starts a real band sync, rather than scheduling a discretionary background refresh or simulating a refresh gesture. + +Expand the action to enable **Ignore Connectivity Errors**. It is off by default. When enabled, Bluetooth being unavailable or the paired band being unreachable returns a successful text result beginning with `Skipped:` instead of throwing an action error. Missing pairing, denied Bluetooth permission, startup failures, and other failures are not suppressed. The action does not display a dialog or post a notification of its own. This option cannot disable notifications or progress UI that iOS or Shortcuts independently chooses to display. + +For a personal automation, choose a Time of Day trigger, select **Run Immediately** where offered, and run the shortcut. Configure the automation's own notification options separately. Several daily triggers can provide several sync opportunities; they do not guarantee that the band is reachable or that iOS will finish every invocation. + +### Results + +- **Band data synchronized:** the band transfer completed and Edge requested light processing and refreshed its widget snapshot. An existing derivation takes precedence. This is not a claim that every historical day has received full heavy analysis or that HealthKit export completed. +- **Sync is incomplete:** the invocation ran out of time or the transfer ended early. Previously saved data is retained. Run the action again or open Edge for a longer catch-up. +- **A sync request is already active:** another request owns the sync path. No competing transfer was started. +- **Skipped:** one of the two opted-out connectivity failures occurred; no successful sync is claimed. + +The ordinary action has a 25-second native deadline, including Flutter startup. The Dart transfer receives a slightly shorter budget so it can report partial progress before that deadline. An iOS interruption can still prevent a result from being returned. Apple's ordinary App Intent execution budget is approximately 30 seconds; see [LongRunningIntent](https://developer.apple.com/documentation/appintents/longrunningintent). + +## Lifecycle and data safety + +For an interactive fallback, **Open Edge and Sync** brings the app forward and invokes the same sync bridge. It is not intended for unattended locked-device automations. The action itself remains bounded, but an app-owned session can keep catching up while Edge is open. + +The application retains one headless-capable Flutter engine. Foreground scenes attach to that same engine, keeping the UI, background wakes, and Shortcuts in one isolate with the same band-ownership guards. The native bridge waits for an explicit Dart readiness handshake, correlates replies with requests, and ignores late replies after cancellation. + +An existing app-owned connection and sync burst are reused. Otherwise the action takes the existing headless gate and band lease and uses the same `BleEngine` and `BandHost` persistence callbacks as normal background sync. Records and the durable cursor are committed before a batch is acknowledged to the band. + +Cancellation or a deadline stops an action-owned connection. Its gate and lease remain held until serialized BLE cleanup finishes. Cancelling a Shortcut does not disconnect an independent app-owned live session; that session can continue its normal synchronization. A Shortcut never pretends to finish merely because it started asynchronous work. + +## Validation + +The Dart task tests cover completion, deadline classification, cancellation, progress suppression after cancellation, and ownership retention during cleanup. The iOS Runner tests cover bridge readiness, concurrent requests, late replies, cancellation, malformed responses, and the exact connectivity-error allowlist. A clean-simulator integration test calls through the real Flutter bridge and expects a missing-pairing error rather than success. + +The separate **ShortcutIntents** Xcode scheme uses Apple's `AppIntentsTesting` framework on iOS 27 to exercise background invocation, relaunch after termination, and the foreground fallback through the system's intent infrastructure. It does not replace the ordinary **Runner** scheme or raise the application's deployment target. Use a clean simulator and a separate bundle identifier so these tests cannot use a real pairing or change an existing installation: + +```sh +flutter pub get +flutter build ios --simulator --debug +cd ios +xcodebuild -workspace Runner.xcworkspace -scheme Runner \ + -destination "id=$SIMULATOR_ID" -only-testing:RunnerTests \ + BUILD_DIR="$PWD/../build/ios" CODE_SIGNING_ALLOWED=NO \ + APP_BUNDLE_IDENTIFIER=com.example.openstrapEdge.shortcuttests \ + APP_GROUP_IDENTIFIER=group.com.example.openstrap.shortcuttests test +``` + +For the system-invocation tests, use Xcode 27 with an iOS 27 simulator and replace `-scheme Runner -only-testing:RunnerTests` with `-scheme ShortcutIntents`. Unlike the native Runner tests, `AppIntentsTesting` requires the app and UI test runner to be development-signed by the same team. Replace `CODE_SIGNING_ALLOWED=NO` with `CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY="Apple Development" DEVELOPMENT_TEAM="$TEAM_ID" APPLE_DEVELOPMENT_TEAM="$TEAM_ID"`, where `TEAM_ID` is your Apple development team. See [Apple's AppIntentsTesting walkthrough](https://developer.apple.com/videos/play/wwdc2026/295/). + +Simulator tests do not establish real Bluetooth transfer reliability. Before relying on an unattended automation, test on an iPhone with a paired band: + +| Scenario | Expected behavior | +| --- | --- | +| Edge open, then suspended | The same connection is reused; no duplicate drain. | +| Edge terminated, then action invoked | Flutter starts without requiring a visible scene and the action reaches the sync service. | +| Phone locked | The action can attempt to run, subject to storage availability and iOS policy. | +| Bluetooth off or band out of range | Error with the option off; successful `Skipped:` result with it on. | +| Bluetooth permission denied | Error with either option value. | +| Large backlog or cancellation | No false completion or ACK of unsaved data; a later run can resume. | +| UI refresh overlaps the action | Only one transfer owns the band. | + +Test force-quit followed by a scheduled personal automation separately from Bluetooth state restoration. These are different launch mechanisms; neither a foreground test nor a Simulator test proves the force-quit/locked-device scheduling case. diff --git a/ios/OpenStrapIntents.swift b/ios/OpenStrapIntents.swift index c8578eb60..a28e3652c 100644 --- a/ios/OpenStrapIntents.swift +++ b/ios/OpenStrapIntents.swift @@ -114,14 +114,53 @@ struct SleepIntent: AppIntent { // MARK: - Action intents (these actually DO something, not just answer) -/// "Start breathing" — unlike the query intents above, this needs the live -/// Flutter engine + BLE stack (a guided session reads live RR from the band), -/// so it must open the app rather than answer standalone. Writes the target -/// route into the App Group; the Dart side picks it up via -/// WidgetService.consumePendingRoute() on launch AND on every foreground -/// resume (see AppState.checkPendingSiriRoute — openAppWhenRun doesn't -/// guarantee a fresh launch, it may just foreground an already-running -/// process, so both call sites matter). +@available(iOS 16.0, *) +struct SyncDataIntent: AppIntent { + static var title: LocalizedStringResource = "Sync Data" + static var description = IntentDescription( + "Sync your paired band into Edge without opening the app. Large backlogs may need more than one run.") + static var openAppWhenRun = false + static var authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed + + @Parameter(title: "Ignore Connectivity Errors", description: + "Skip quietly when Bluetooth is unavailable or the band cannot be reached. Pairing, permission, and other errors are still reported.", default: false) + var ignoreConnectivityErrors: Bool + + static var parameterSummary: some ParameterSummary { + Summary("Sync band data") { \.$ignoreConnectivityErrors } + } + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + .result(value: try await syncMessage(using: .shared)) + } + + @MainActor + func syncMessage(using bridge: ShortcutSyncBridge) async throws -> String { + do { + return try await bridge.sync().message + } catch let error as ShortcutSyncFailure where ignoreConnectivityErrors && error.canIgnore { + return "Skipped: \(error.localizedDescription)" + } + } +} + +@available(iOS 16.0, *) +struct OpenEdgeAndSyncIntent: AppIntent { + static var title: LocalizedStringResource = "Open Edge and Sync" + static var description = IntentDescription( + "Open Edge and sync your paired band. Use this interactive action for catch-up that needs the app in front.") + static var openAppWhenRun = true + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + let reply = try await ShortcutSyncBridge.shared.sync() + return .result(value: reply.message) + } +} + +/// Flutter consumes the breathing route on launch and resume because opening +/// the app does not guarantee a fresh process. @available(iOS 16.0, *) struct StartBreathingIntent: AppIntent { static var title: LocalizedStringResource = "Start Breathing Session" @@ -176,6 +215,12 @@ struct EnableTomorrowAlarmIntent: AppIntent { @available(iOS 16.0, *) struct OpenStrapShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: SyncDataIntent(), + phrases: ["Sync data in \(.applicationName)", "Sync my band with \(.applicationName)"], + shortTitle: "Sync Data", + systemImageName: "arrow.triangle.2.circlepath") + AppShortcut( intent: RecoveryIntent(), phrases: [ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 10d120817..838823c66 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -20,16 +20,19 @@ 534897A72FDC2B310033A4D9 /* LiveActivityBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 534897A62FDC2B310033A4D9 /* LiveActivityBridge.swift */; }; 53962E962FF6EE120061A61B /* WatchBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53962E952FF6EE120061A61B /* WatchBridge.swift */; }; 53962E972FF6EE120061A61B /* OpenStrapIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53962E942FF6EE120061A61B /* OpenStrapIntents.swift */; }; + 584C82D4C91D63C59A9B68ED /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A8259C983D095EF84D51BCF /* Foundation.framework */; }; + 5A0C00000000000000000001 /* ShortcutSyncBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A0C00000000000000000002 /* ShortcutSyncBridge.swift */; }; 60DE9D949573401269D6DF2E /* HealthRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46A2B400A52A2CA90242C195 /* HealthRoutes.swift */; }; - B9D2F406183A5C7E92B1D3F5 /* HealthKitSleepWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */; }; 630172CC9317145AD5F8F3B7 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C721EA0C8D31A3834E66203 /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 7A75696EB0AB2F0DFBECE037 /* ShortcutUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BB57590968BB44C5559E37A /* ShortcutUITests.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; ACCE55E7000000000000B11D /* AccessorySetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACCE55E7000000000000F11E /* AccessorySetup.swift */; }; + B9D2F406183A5C7E92B1D3F5 /* HealthKitSleepWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */; }; BEEF00000000000000000002 /* BreathingLiveActivityBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF00000000000000000001 /* BreathingLiveActivityBridge.swift */; }; FADE0001FADE0001FADE0001 /* OpenStrapWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 53962EBE2FF6EF790061A61B /* OpenStrapWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -49,6 +52,13 @@ remoteGlobalIDString = 5348973A2FDC19C80033A4D9; remoteInfo = OpenStrapWidgetExtension; }; + 99D9F39CEDD34D72655792B9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; FADE0003FADE0003FADE0003 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -97,13 +107,15 @@ 0BGTASK00000000000000002 /* BgSyncScheduler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BgSyncScheduler.swift; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1A8259C983D095EF84D51BCF /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; + 1BB57590968BB44C5559E37A /* ShortcutUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShortcutUITests.swift; sourceTree = ""; }; 2C721EA0C8D31A3834E66203 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3238201D56A4B5F97DB8D416 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 46A2B400A52A2CA90242C195 /* HealthRoutes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HealthRoutes.swift; sourceTree = ""; }; - A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HealthKitSleepWriter.swift; sourceTree = ""; }; + 4A03AF838C04AE2BCB84D0B1 /* Signing.defaults.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = Signing.defaults.xcconfig; path = Config/Signing.defaults.xcconfig; sourceTree = ""; }; 5348973B2FDC19C80033A4D9 /* OpenStrapWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenStrapWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5348973C2FDC19C80033A4D9 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 5348973E2FDC19C80033A4D9 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; @@ -113,6 +125,7 @@ 53962E942FF6EE120061A61B /* OpenStrapIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenStrapIntents.swift; sourceTree = ""; }; 53962E952FF6EE120061A61B /* WatchBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchBridge.swift; sourceTree = ""; }; 53962EBE2FF6EF790061A61B /* OpenStrapWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "OpenStrapWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5A0C00000000000000000002 /* ShortcutSyncBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutSyncBridge.swift; sourceTree = ""; }; 606824C7302BC5108CEC40DF /* BleRestoreManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BleRestoreManager.swift; sourceTree = ""; }; 62B87A5BFE3C75EF3DA30015 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; 6A2B37C42FDD000100000001 /* Signing.defaults.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.defaults.xcconfig; sourceTree = ""; }; @@ -131,6 +144,8 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9AB0D2B3B0A362AED6D7BFB0 /* ShortcutUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ShortcutUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HealthKitSleepWriter.swift; sourceTree = ""; }; ACCE55E7000000000000F11E /* AccessorySetup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccessorySetup.swift; sourceTree = ""; }; B0B95B9F07C8083A2DB92078 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; BEEF00000000000000000001 /* BreathingLiveActivityBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreathingLiveActivityBridge.swift; sourceTree = ""; }; @@ -209,6 +224,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F5BCA3444885F1EC6DE66F02 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 584C82D4C91D63C59A9B68ED /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -220,6 +243,14 @@ path = RunnerTests; sourceTree = ""; }; + 4CE453DC14416DB9F3395C14 /* iOS */ = { + isa = PBXGroup; + children = ( + 1A8259C983D095EF84D51BCF /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; 6A2B37C32FDD000100000001 /* Config */ = { isa = PBXGroup; children = ( @@ -236,6 +267,7 @@ 2C721EA0C8D31A3834E66203 /* Pods_RunnerTests.framework */, 5348973C2FDC19C80033A4D9 /* WidgetKit.framework */, 5348973E2FDC19C80033A4D9 /* SwiftUI.framework */, + 4CE453DC14416DB9F3395C14 /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -270,6 +302,8 @@ AF81963B4F996986D8D5C105 /* Pods */, 7AE58D6D3A9D5791559034B2 /* Frameworks */, 62B87A5BFE3C75EF3DA30015 /* GoogleService-Info.plist */, + D4347D747D013F5BEE68EE80 /* ShortcutUITests */, + 4A03AF838C04AE2BCB84D0B1 /* Signing.defaults.xcconfig */, ); sourceTree = ""; }; @@ -280,6 +314,7 @@ 331C8081294A63A400263BE5 /* RunnerTests.xctest */, 5348973B2FDC19C80033A4D9 /* OpenStrapWidgetExtension.appex */, 53962EBE2FF6EF790061A61B /* OpenStrapWatch Watch App.app */, + 9AB0D2B3B0A362AED6D7BFB0 /* ShortcutUITests.xctest */, ); name = Products; sourceTree = ""; @@ -300,6 +335,7 @@ 606824C7302BC5108CEC40DF /* BleRestoreManager.swift */, ACCE55E7000000000000F11E /* AccessorySetup.swift */, 0BGTASK00000000000000002 /* BgSyncScheduler.swift */, + 5A0C00000000000000000002 /* ShortcutSyncBridge.swift */, 46A2B400A52A2CA90242C195 /* HealthRoutes.swift */, A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */, ); @@ -319,6 +355,15 @@ path = Pods; sourceTree = ""; }; + D4347D747D013F5BEE68EE80 /* ShortcutUITests */ = { + isa = PBXGroup; + children = ( + 1BB57590968BB44C5559E37A /* ShortcutUITests.swift */, + ); + name = ShortcutUITests; + path = ShortcutUITests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -413,6 +458,24 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + A7883417CA432F4099968E48 /* ShortcutUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6E1CB66ACEA4368C94A0B7DE /* Build configuration list for PBXNativeTarget "ShortcutUITests" */; + buildPhases = ( + 8D8D0870FF2A8AFF5F8333AD /* Sources */, + F5BCA3444885F1EC6DE66F02 /* Frameworks */, + 33BEE06C1A24DD3177BF613B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ED3603C44AC0D2B2B09121D8 /* PBXTargetDependency */, + ); + name = ShortcutUITests; + productName = ShortcutUITests; + productReference = 9AB0D2B3B0A362AED6D7BFB0 /* ShortcutUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -439,6 +502,10 @@ CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; + A7883417CA432F4099968E48 = { + CreatedOnToolsVersion = 27.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -461,6 +528,7 @@ 331C8080294A63A400263BE5 /* RunnerTests */, 5348973A2FDC19C80033A4D9 /* OpenStrapWidgetExtension */, 53962EBD2FF6EF790061A61B /* OpenStrapWatch Watch App */, + A7883417CA432F4099968E48 /* ShortcutUITests */, ); }; /* End PBXProject section */ @@ -473,6 +541,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 33BEE06C1A24DD3177BF613B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 534897392FDC19C80033A4D9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -668,6 +743,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8D8D0870FF2A8AFF5F8333AD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7A75696EB0AB2F0DFBECE037 /* ShortcutUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -682,6 +765,7 @@ 46AF580757DC25648B6B0C95 /* BleRestoreManager.swift in Sources */, ACCE55E7000000000000B11D /* AccessorySetup.swift in Sources */, 0BGTASK00000000000000001 /* BgSyncScheduler.swift in Sources */, + 5A0C00000000000000000001 /* ShortcutSyncBridge.swift in Sources */, 60DE9D949573401269D6DF2E /* HealthRoutes.swift in Sources */, B9D2F406183A5C7E92B1D3F5 /* HealthKitSleepWriter.swift in Sources */, ); @@ -700,6 +784,12 @@ target = 5348973A2FDC19C80033A4D9 /* OpenStrapWidgetExtension */; targetProxy = 5348974C2FDC19C90033A4D9 /* PBXContainerItemProxy */; }; + ED3603C44AC0D2B2B09121D8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Runner; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 99D9F39CEDD34D72655792B9 /* PBXContainerItemProxy */; + }; FADE0004FADE0004FADE0004 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 53962EBD2FF6EF790061A61B /* OpenStrapWatch Watch App */; @@ -784,8 +874,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = AppIconBW; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; @@ -1153,6 +1243,68 @@ }; name = Profile; }; + 73AC66811EC71FB6D7F6FC1D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4A03AF838C04AE2BCB84D0B1 /* Signing.defaults.xcconfig */; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; + ENABLE_TESTING_SEARCH_PATHS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShortcutUITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShortcutUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 81FCB2BD13A4A8092AEB0B45 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4A03AF838C04AE2BCB84D0B1 /* Signing.defaults.xcconfig */; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; + ENABLE_TESTING_SEARCH_PATHS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShortcutUITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShortcutUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + }; + name = Debug; + }; + 8E4ADCACD0F39BFB1CCE95F8 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4A03AF838C04AE2BCB84D0B1 /* Signing.defaults.xcconfig */; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)"; + ENABLE_TESTING_SEARCH_PATHS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShortcutUITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShortcutUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 6A2B37C42FDD000100000001 /* Signing.defaults.xcconfig */; @@ -1270,8 +1422,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = AppIconBW; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; @@ -1301,8 +1453,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = AppIconBW; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; @@ -1360,6 +1512,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 6E1CB66ACEA4368C94A0B7DE /* Build configuration list for PBXNativeTarget "ShortcutUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 73AC66811EC71FB6D7F6FC1D /* Release */, + 81FCB2BD13A4A8092AEB0B45 /* Debug */, + 8E4ADCACD0F39BFB1CCE95F8 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/ShortcutIntents.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/ShortcutIntents.xcscheme new file mode 100644 index 000000000..0de1269b8 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/ShortcutIntents.xcscheme @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index d92e2bd50..293ff710e 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -6,7 +6,15 @@ import BackgroundTasks import CoreMotion @main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { +@objc class AppDelegate: FlutterAppDelegate { + // One isolate keeps the UI, background wakes, and Shortcuts under the same BLE ownership gate. + lazy var sharedEngine: FlutterEngine = { + let engine = FlutterEngine(name: "openstrap", project: nil, allowHeadlessExecution: true) + engine.run() + registerEngine(engine) + return engine + }() + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -16,7 +24,7 @@ import CoreMotion BleRestoreManager.shared.start(launchOptions: launchOptions) // BGTaskScheduler registration MUST happen before didFinishLaunching returns. - // The channel wiring (messenger) happens in didInitializeImplicitFlutterEngine below; + // The channel wiring (messenger) happens in registerEngine below; // here we only register the identifier with the OS so it survives to that point. // schedule() is called after the channel is wired so Dart is ready to handle the task. BGTaskScheduler.shared.register( @@ -47,6 +55,7 @@ import CoreMotion // paired watch. See WatchBridge.swift. WatchBridge.shared.activate() + _ = sharedEngine return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @@ -63,56 +72,59 @@ import CoreMotion WatchBridge.shared.pushCurrentState() } - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + private func registerEngine(_ registry: FlutterPluginRegistry) { + GeneratedPluginRegistrant.register(with: registry) // Live Activity MethodChannel (start/update/end the workout activity). // LiveActivityBridge lives in LiveActivityBridge.swift (Runner target). - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "LiveActivityBridge") { + if let registrar = registry.registrar(forPlugin: "LiveActivityBridge") { LiveActivityBridge.register(messenger: registrar.messenger()) } // Breathing-session Live Activity — separate channel/attributes type from // the workout one (BreathingLiveActivityBridge.swift, Runner target). - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BreathingLiveActivityBridge") { + if let registrar = registry.registrar(forPlugin: "BreathingLiveActivityBridge") { BreathingLiveActivityBridge.register(messenger: registrar.messenger()) } // BLE-restore channel: native wake (band reconnected) → Dart headless sync. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BleRestoreManager") { + if let registrar = registry.registrar(forPlugin: "BleRestoreManager") { BleRestoreManager.shared.attach(messenger: registrar.messenger()) } // Band-gesture actions channel (double-tap → play/pause, skip, ring phone). - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "ActionBridge") { + if let registrar = registry.registrar(forPlugin: "ActionBridge") { ActionBridge.register(messenger: registrar.messenger()) } // AccessorySetupKit pairing bridge (iOS 18+). The ASK picker provisions the WHOOP so // iOS 26 keeps the app eligible for background relaunch (TN3115). No-op pre-iOS 18. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "AccessorySetup") { + if let registrar = registry.registrar(forPlugin: "AccessorySetup") { AccessorySetup.register(messenger: registrar.messenger()) } // Home-screen icon switching (setAlternateIconName). iOS only — see the // bridge below for the system-alert cost it carries. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "AppIconBridge") { + if let registrar = registry.registrar(forPlugin: "AppIconBridge") { AppIconBridge.register(messenger: registrar.messenger()) } // Build-time iOS configuration exposed to Dart without requiring --dart-define. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "ConfigBridge") { + if let registrar = registry.registrar(forPlugin: "ConfigBridge") { ConfigBridge.register(messenger: registrar.messenger()) } // The phone's OWN step count (CMPedometer), not HealthKit's multi-writer // aggregate. See lib/health/phone_pedometer.dart. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "PedometerBridge") { + if let registrar = registry.registrar(forPlugin: "PedometerBridge") { PedometerBridge.register(messenger: registrar.messenger()) } // HKWorkoutRoute → Dart. Coordinates only; the `health` plugin still reads // the workouts themselves. See lib/health/health_workout_import.dart. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "HealthRouteBridge") { + if let registrar = registry.registrar(forPlugin: "HealthRouteBridge") { HealthRouteBridge.register(messenger: registrar.messenger()) } // HealthKit sleep replace (inBed + Core/Deep/REM). See HealthKitSleepWriter.swift. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "HealthKitSleepWriter") { + if let registrar = registry.registrar(forPlugin: "HealthKitSleepWriter") { HealthKitSleepWriter.register(messenger: registrar.messenger()) } + if let registrar = registry.registrar(forPlugin: "ShortcutSyncBridge") { + ShortcutSyncBridge.shared.attach(messenger: registrar.messenger()) + } // BGTask channel: Dart handler for opportunistic headless sync + heavy derivation. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BackgroundTaskManager") { + if let registrar = registry.registrar(forPlugin: "BackgroundTaskManager") { BackgroundTaskManager.wireChannel(messenger: registrar.messenger()) // Now that the channel is wired, submit the first task requests // (heavy processing + light sync-only refresh). diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index e04f934c8..aa08690ac 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -156,8 +156,6 @@ flutter UISceneDelegateClassName $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main @@ -199,8 +197,6 @@ UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift index a6a09329c..0dd56b615 100644 --- a/ios/Runner/SceneDelegate.swift +++ b/ios/Runner/SceneDelegate.swift @@ -2,6 +2,18 @@ import Flutter import UIKit class SceneDelegate: FlutterSceneDelegate { + override func scene(_ scene: UIScene, willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + guard let scene = scene as? UIWindowScene, + let delegate = UIApplication.shared.delegate as? AppDelegate else { return } + let window = UIWindow(windowScene: scene) + window.rootViewController = FlutterViewController( + engine: delegate.sharedEngine, nibName: nil, bundle: nil) + self.window = window + window.makeKeyAndVisible() + super.scene(scene, willConnectTo: session, options: connectionOptions) + } + /// Re-submit the BGProcessingTask + BGAppRefreshTask requests every time a /// scene enters the background so iOS always has pending requests to fire /// opportunistically. This is the correct hook in a UISceneDelegate-based app diff --git a/ios/Runner/ShortcutSyncBridge.swift b/ios/Runner/ShortcutSyncBridge.swift new file mode 100644 index 000000000..bf58dcfda --- /dev/null +++ b/ios/Runner/ShortcutSyncBridge.swift @@ -0,0 +1,160 @@ +import Flutter +import Foundation + +struct ShortcutSyncFailure: LocalizedError { + let code: String + + var canIgnore: Bool { code == "bluetoothUnavailable" || code == "bandUnreachable" } + + var errorDescription: String? { + switch code { + case "bluetoothUnavailable": return "Bluetooth is unavailable." + case "bandUnreachable": return "The paired band could not be reached." + case "permissionDenied": return "Allow Edge to use Bluetooth in Settings." + case "notPaired": return "Open Edge and pair your band first." + case "setupRequired": return "Finish accessory setup in Edge before syncing." + case "timedOut": return "Edge did not finish the sync before its execution deadline." + case "cancelled": return "Sync was cancelled." + default: return "Edge could not complete the sync. Open Edge to check the connection and storage." + } + } +} + +struct ShortcutSyncReply { + let status: String + let records: Int + + init(_ value: Any?) throws { + guard let map = value as? [String: Any], + let status = map["status"] as? String, + let records = map["records"] as? Int, records >= 0 else { + throw ShortcutSyncFailure(code: "invalidResponse") + } + guard ["complete", "partial", "alreadyRunning"].contains(status) else { + throw ShortcutSyncFailure(code: status) + } + self.status = status + self.records = records + } + + var message: String { + switch status { + case "complete": return "Band data synchronized." + case "partial": return "Sync is incomplete. Saved data is retained; run Sync Data again or open Edge to catch up." + default: return "A sync request is already active; no second sync was started." + } + } +} + +@MainActor +final class ShortcutSyncBridge { + static let shared = ShortcutSyncBridge() + typealias Sender = (String, Any?, @escaping FlutterResult) -> Void + + private var channel: FlutterMethodChannel? + private var send: Sender? + private var ready = false + private var pending: Pending? + + private final class Pending { + let id: String + let deadline: TimeInterval + let continuation: CheckedContinuation + let progress: (([String: Any]) -> Void)? + var watchdog: Task? + var sent = false + + init(id: String, timeout: TimeInterval, + continuation: CheckedContinuation, + progress: (([String: Any]) -> Void)?) { + self.id = id + self.deadline = ProcessInfo.processInfo.systemUptime + timeout + self.continuation = continuation + self.progress = progress + } + } + + init(send: Sender? = nil) { self.send = send } + + func attach(messenger: FlutterBinaryMessenger) { + let channel = FlutterMethodChannel(name: "openstrap/shortcut_sync", binaryMessenger: messenger) + self.channel = channel + send = { method, arguments, reply in + channel.invokeMethod(method, arguments: arguments, result: reply) + } + channel.setMethodCallHandler { [weak self] call, result in + MainActor.assumeIsolated { + self?.receive(call, result: result) + } + } + } + + func receive(_ call: FlutterMethodCall, result: FlutterResult) { + switch call.method { + case "ready": + ready = true + result(nil) + dispatchPending() + case "progress": + if let update = call.arguments as? [String: Any], + let id = update["id"] as? String, id == pending?.id { + pending?.progress?(update) + } + result(nil) + default: result(FlutterMethodNotImplemented) + } + } + + func sync(id: String = UUID().uuidString, timeout: TimeInterval = 25, + progress: (([String: Any]) -> Void)? = nil) async throws -> ShortcutSyncReply { + try Task.checkCancellation() + guard pending == nil else { + return try ShortcutSyncReply(["status": "alreadyRunning", "records": 0]) + } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let request = Pending(id: id, timeout: timeout, continuation: continuation, progress: progress) + pending = request + request.watchdog = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000)) + } catch { return } + self?.cancel(id: id, code: "timedOut") + } + dispatchPending() + } + } onCancel: { + Task { @MainActor in self.cancel(id: id) } + } + } + + func cancel(id: String, code: String = "cancelled") { + guard let request = pending, request.id == id else { return } + if request.sent { send?("cancel", ["id": id], { _ in }) } + finish(id: id, result: .failure(ShortcutSyncFailure(code: code))) + } + + private func dispatchPending() { + guard ready, let request = pending, !request.sent, let send else { return } + let remaining = request.deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { + cancel(id: request.id, code: "timedOut") + return + } + request.sent = true + // Leave time for Dart to return a partial/unreachable result before the native watchdog. + let budget = max(1, Int((remaining - min(2, remaining / 10)) * 1000)) + send("run", ["id": request.id, "budgetMs": budget]) { [weak self] reply in + MainActor.assumeIsolated { + self?.finish(id: request.id, result: Result { try ShortcutSyncReply(reply) }) + } + } + } + + private func finish(id: String, result: Result) { + guard let request = pending, request.id == id else { return } + pending = nil + request.watchdog?.cancel() + request.continuation.resume(with: result) + } +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 86a7c3b1b..2d2de79b6 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -1,12 +1,167 @@ import Flutter import UIKit import XCTest +import AppIntents +@testable import Runner +@MainActor class RunnerTests: XCTestCase { - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + private func ready(_ bridge: ShortcutSyncBridge) { + bridge.receive(FlutterMethodCall(methodName: "ready", arguments: nil)) { _ in } + } + + func testReplyNeverTreatsMissingOrMalformedResponseAsSuccess() { + let values: [Any?] = [nil, true, [:], ["status": "complete"], + ["status": "complete", "records": -1], + FlutterError(code: "storage", message: "Failed", details: nil)] + for value in values { + XCTAssertThrowsError(try ShortcutSyncReply(value)) + } + XCTAssertEqual(try ShortcutSyncReply(["status": "complete", "records": 17]).records, 17) + XCTAssertEqual(try ShortcutSyncReply(["status": "partial", "records": 3]).status, "partial") + } + + func testWaitsForDartReadinessAndDispatchesOnlyOnce() async throws { + var runs = 0 + let bridge = ShortcutSyncBridge { method, args, reply in + XCTAssertEqual(method, "run") + XCTAssertGreaterThan((args as! [String: Any])["budgetMs"] as! Int, 0) + runs += 1 + reply(["status": "complete", "records": 8]) + } + let request = Task { try await bridge.sync(timeout: 1) } + await Task.yield() + XCTAssertEqual(runs, 0) + ready(bridge) + let response = try await request.value + XCTAssertEqual(response.records, 8) + ready(bridge) + XCTAssertEqual(runs, 1) + } + + func testOverlappingRequestsDoNotStartAnotherSync() async throws { + var reply: FlutterResult? + let bridge = ShortcutSyncBridge { _, _, result in reply = result } + ready(bridge) + let first = Task { try await bridge.sync(timeout: 1) } + while reply == nil { await Task.yield() } + let second = try await bridge.sync(timeout: 1) + XCTAssertEqual(second.status, "alreadyRunning") + reply?(["status": "complete", "records": 2]) + let response = try await first.value + XCTAssertEqual(response.status, "complete") + } + + func testDeadlineCancelsAndIgnoresLateReply() async throws { + var runReply: FlutterResult? + var cancelledId: String? + let bridge = ShortcutSyncBridge { method, args, reply in + if method == "run" { runReply = reply } + if method == "cancel" { cancelledId = (args as? [String: Any])?["id"] as? String } + } + ready(bridge) + do { + _ = try await bridge.sync(id: "expired", timeout: 0.02) + XCTFail("A deadline must not report completion") + } catch let error as ShortcutSyncFailure { + XCTAssertEqual(error.code, "timedOut") + XCTAssertFalse(error.canIgnore) + } + XCTAssertEqual(cancelledId, "expired") + runReply?(["status": "complete", "records": 99]) + } + + func testTaskCancellationIsForwardedToMatchingRequest() async throws { + var started = false + var cancelled = false + let bridge = ShortcutSyncBridge { method, args, _ in + if method == "run" { started = true } + if method == "cancel" { + cancelled = (args as? [String: Any])?["id"] as? String == "cancel-me" + } + } + ready(bridge) + let request = Task { try await bridge.sync(id: "cancel-me", timeout: 1) } + while !started { await Task.yield() } + bridge.cancel(id: "some-other-request") + XCTAssertFalse(cancelled) + request.cancel() + do { + _ = try await request.value + XCTFail("Cancelled request completed") + } catch let error as ShortcutSyncFailure { + XCTAssertEqual(error.code, "cancelled") + } + XCTAssertTrue(cancelled) + } + + func testMissingReadinessDoesNotDispatchALateSync() async throws { + var dispatched = false + let bridge = ShortcutSyncBridge { _, _, _ in dispatched = true } + do { + _ = try await bridge.sync(timeout: 0.02) + XCTFail("Missing readiness must time out") + } catch let error as ShortcutSyncFailure { + XCTAssertEqual(error.code, "timedOut") + } + ready(bridge) + XCTAssertFalse(dispatched) + } + + func testProgressBelongsOnlyToTheActiveRequest() async throws { + var reply: FlutterResult? + var updates = 0 + let bridge = ShortcutSyncBridge { _, _, result in reply = result } + ready(bridge) + let request = Task { + try await bridge.sync(id: "active", timeout: 5, progress: { _ in updates += 1 }) + } + while reply == nil { await Task.yield() } + for id in ["old", "active"] { + bridge.receive(FlutterMethodCall(methodName: "progress", + arguments: ["id": id, "phase": "syncing", "batches": 2])) { _ in } + } + XCTAssertEqual(updates, 1) + reply?(["status": "complete", "records": 2]) + _ = try await request.value + bridge.receive(FlutterMethodCall(methodName: "progress", + arguments: ["id": "active"])) { _ in } + XCTAssertEqual(updates, 1) + } + + func testSyncIntentIgnoresOnlyOptedInConnectivityErrors() async throws { + guard #available(iOS 16.0, *) else { throw XCTSkip("App Intents require iOS 16") } + for code in ["bluetoothUnavailable", "bandUnreachable", "permissionDenied", + "notPaired", "setupRequired", "failed", "timedOut", "cancelled"] { + for ignore in [false, true] { + let bridge = ShortcutSyncBridge { _, _, reply in + reply(["status": code, "records": 0]) + } + ready(bridge) + var intent = SyncDataIntent() + intent.ignoreConnectivityErrors = ignore + let shouldIgnore = ignore && ["bluetoothUnavailable", "bandUnreachable"].contains(code) + do { + let message = try await intent.syncMessage(using: bridge) + XCTAssertTrue(shouldIgnore, "Unexpected suppression of \(code)") + XCTAssertTrue(message.hasPrefix("Skipped:")) + } catch let error as ShortcutSyncFailure { + XCTAssertFalse(shouldIgnore, "Expected suppression of \(code)") + XCTAssertEqual(error.code, code) + } + } + } + } + + func testRealFlutterBridgeReportsUnpairedInsteadOfFalseSuccess() async throws { + XCTAssertNotNil((UIApplication.shared.delegate as? AppDelegate)?.sharedEngine) + do { + _ = try await ShortcutSyncBridge.shared.sync(timeout: 25) + XCTFail("The clean simulator has no paired band") + } catch let error as ShortcutSyncFailure { + XCTAssertEqual(error.code, "notPaired") + } } } diff --git a/ios/ShortcutUITests/Info.plist b/ios/ShortcutUITests/Info.plist new file mode 100644 index 000000000..56e6a7f4e --- /dev/null +++ b/ios/ShortcutUITests/Info.plist @@ -0,0 +1,8 @@ + + + + + TestedAppBundleIdentifier + $(APP_BUNDLE_IDENTIFIER) + + diff --git a/ios/ShortcutUITests/ShortcutUITests.swift b/ios/ShortcutUITests/ShortcutUITests.swift new file mode 100644 index 000000000..25cd15fc8 --- /dev/null +++ b/ios/ShortcutUITests/ShortcutUITests.swift @@ -0,0 +1,80 @@ +#if canImport(AppIntentsTesting) +import AppIntentsTesting +import XCTest + +@available(iOS 27.0, *) +@MainActor +final class ShortcutUITests: XCTestCase { + private let app = XCUIApplication() + + private var definitions: IntentDefinitions { + get throws { + let identifier = try XCTUnwrap(Bundle(for: Self.self).object( + forInfoDictionaryKey: "TestedAppBundleIdentifier") as? String) + return IntentDefinitions(bundleIdentifier: identifier) + } + } + + override func setUpWithError() throws { + continueAfterFailure = false + app.launch() + } + + func testSyncFromBackgroundUsesRealAppIntentInfrastructure() async throws { + backgroundApp() + try await expectPairingError("SyncDataIntent") + XCTAssertNotEqual(app.state, .runningForeground) + } + + func testForegroundFallbackUsesTheSameSyncEntryPoint() async throws { + backgroundApp() + do { + _ = try await definitions.intents["OpenEdgeAndSyncIntent"].makeIntent().run() + XCTFail("The interactive action must not invent a successful sync") + } catch { + XCTAssertTrue(String(describing: error).contains("Open Edge and pair your band first."), "\(error)") + } + XCTAssertEqual(app.state, .runningForeground) + } + + func testSyncRelaunchesTerminatedAppWithoutOpeningAWindow() async throws { + let intent = try definitions.intents["SyncDataIntent"] + .makeIntent(ignoreConnectivityErrors: true) + app.terminate() + do { + _ = try await intent.run() + XCTFail("A clean simulator cannot successfully sync an unpaired band") + } catch { + XCTAssertTrue(String(describing: error).contains("Open Edge and pair your band first."), "\(error)") + } + XCTAssertNotEqual(app.state, .runningForeground) + app.activate() + XCTAssertTrue(app.buttons.firstMatch.waitForExistence(timeout: 15), + "The foreground scene must render after a headless engine launch") + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Edge after background Shortcut launch" + screenshot.lifetime = .keepAlways + add(screenshot) + } + + private func backgroundApp() { + XCUIDevice.shared.press(.home) + let background = XCTNSPredicateExpectation( + predicate: NSPredicate { [app] _, _ in + app.state == .runningBackground || app.state == .runningBackgroundSuspended + }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [background], timeout: 10), .completed, + "The Home transition must finish before invoking the intent") + } + + private func expectPairingError(_ identifier: String) async throws { + do { + _ = try await definitions.intents[identifier] + .makeIntent(ignoreConnectivityErrors: true).run() + XCTFail("Missing pairing is not an ignorable connectivity failure") + } catch { + XCTAssertTrue(String(describing: error).contains("Open Edge and pair your band first."), "\(error)") + } + } +} +#endif diff --git a/lib/main.dart b/lib/main.dart index 421ba5b50..3f9154e76 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ import 'state/locale_controller.dart'; import 'state/units_controller.dart'; import 'sync/headless_boot.dart'; import 'sync/ios_bg_task.dart'; +import 'sync/ios_shortcut_sync.dart'; import 'theme/theme_controller.dart'; import 'widget/widget_service.dart'; import 'package:firebase_core/firebase_core.dart'; @@ -115,6 +116,8 @@ Future main() async { .timeout(_kStartupInitTimeout); } catch (_) {/* older plugin / unsupported platform — ignore */} + await _safeInit('IosShortcutSync', IosShortcutSync.init); + // Optional startup services. A failure in any one of these must NEVER block the // first frame — they are awaited before runApp, so an unguarded throw (e.g. the // flutter_local_notifications `invalid_icon` crash) leaves the app stuck on the diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 17e219814..4a057a653 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -105,6 +105,8 @@ import '../sync/band_ownership.dart'; import '../sync/high_freq_wake_window.dart'; import '../sync/ios_bg_task.dart'; import '../sync/reset_gate.dart'; +import '../sync/ios_shortcut_sync.dart'; +import '../sync/shortcut_sync_task.dart'; import '../sync/paired_device.dart'; import '../sync/sync_policy.dart' show @@ -1363,22 +1365,27 @@ class AppState extends ChangeNotifier { // `DrainController.commit` still reads durability from a throw. onCommitBatch: (raws, samples, trimTokenHex, {archives, ecgRawPackets, deviceFamily}) async { - // THROWS, never silently succeeds. This is the ACK gate: only - // `onCommit` can bank raws + archives + trim cursor in one - // transaction, and DrainController reads durability FROM A THROW - // (see its safe-trim invariant). Returning quietly here would tell - // the drain the chunk was banked, it would ACK, and the band would - // trim flash that this reset refused to store — turning a race into - // real data loss on a band the user may not be deleting after all if - // the reset then fails. A throw blocks the ACK and the records stay - // on the strap. - if (ResetGate.active) { - throw StateError('data reset in progress — refusing to commit'); + try { + // THROWS, never silently succeeds. This is the ACK gate: only + // `onCommit` can bank raws + archives + trim cursor in one + // transaction, and DrainController reads durability FROM A THROW + // (see its safe-trim invariant). Returning quietly here would tell + // the drain the chunk was banked, it would ACK, and the band would + // trim flash that this reset refused to store — turning a race into + // real data loss on a band the user may not be deleting after all if + // the reset then fails. A throw blocks the ACK and the records stay + // on the strap. + if (ResetGate.active) { + throw StateError('data reset in progress — refusing to commit'); + } + await _bandHost.commitNativeBatch(raws, samples, trimTokenHex, + archives: archives, + ecgRawPackets: ecgRawPackets, + deviceFamily: deviceFamily); + } catch (_) { + IosShortcutSync.foregroundCommitFailed(); + rethrow; } - return _bandHost.commitNativeBatch(raws, samples, trimTokenHex, - archives: archives, - ecgRawPackets: ecgRawPackets, - deviceFamily: deviceFamily); }, // Pre-setup fallback only: the drain path archives inside commitSyncBatch. onArchiveRecord: (raw) async { @@ -1445,6 +1452,8 @@ class AppState extends ChangeNotifier { // skip the headless BLE path (it would fight FBP for the peripheral) — route // them to a catch-up pull over the existing live connection instead. IosBgTask.foregroundPull = foregroundCatchUp; + IosShortcutSync.foregroundSync = syncForShortcut; + IosShortcutSync.foregroundEngine = () => engine; taskerBridge; // force init: register the method channel handler // A paired sensor's live beats, into the same trace as the band's. Touches // no radio — `HrsLink.reading` is a plain notifier whose identity survives @@ -1557,6 +1566,10 @@ class AppState extends ChangeNotifier { @override void dispose() { + if (IosShortcutSync.foregroundSync == syncForShortcut) { + IosShortcutSync.foregroundSync = null; + IosShortcutSync.foregroundEngine = null; + } _syncQuietTimer?.cancel(); _syncQuietTimer = null; _disposed = true; @@ -5033,24 +5046,26 @@ class AppState extends ChangeNotifier { } } - Future openSession() async { + Future openSession({bool foreground = true}) async { if (busy || paired == null) return; BandOwnership.markForegroundIntent(true); _log('[OWNERSHIP] foreground intent on (${BandOwnership.debugState})'); // Returning to the foreground with the connection still alive (kept during // background): don't tear it down and reconnect — just reclaim ownership. final wasBackground = _background; - _background = false; - engine.setBackground(false); + if (foreground) { + _background = false; + engine.setBackground(false); + } // Coming back after hours (or days) suspended: re-read the phone's steps // for whatever day it is NOW. - if (phoneStepsEnabled) { + if (foreground && phoneStepsEnabled) { unawaited(syncPhoneSteps()); } // Back in the foreground with an OS CPU/memory budget again — let the // scheduler drain any derive jobs that queued (durably) while backgrounded. - _deriveScheduler.setBackground(false); - if (wasBackground && engine.isConnected) { + if (foreground) _deriveScheduler.setBackground(false); + if (foreground && wasBackground && engine.isConnected) { IosBleRestore.foregroundActive = true; await IosBleRestore.setOwnsBand(true); EdgeTracking.start(); // Android: keep the foreground service up (idempotent) @@ -5432,6 +5447,37 @@ class AppState extends ChangeNotifier { Future syncNow() => openSession(); + Future syncForShortcut(ShortcutSyncTask task) async { + if (ResetGate.active) throw StateError('data reset in progress'); + if (!initialized) task.update('starting'); + while (!initialized && initError == null && !_disposed && !task.stopped) { + await Future.delayed(const Duration(milliseconds: 50)); + } + if (initError != null || _disposed) throw StateError('Edge is not ready'); + if (busy) task.update('waiting'); + while (busy && !task.stopped) { + await Future.delayed(const Duration(milliseconds: 50)); + } + if (task.stopped) return SyncReport(0, 0, false); + if (ResetGate.active || _disposed) throw StateError('Edge is not ready'); + if (engine.isConnected) { + final usable = await _linkUsableAfterResume('Shortcut'); + if (task.stopped) return SyncReport(0, 0, false); + if (ResetGate.active || _disposed) throw StateError('Edge is not ready'); + if (!usable) await engine.disconnect(); + } + if (!engine.isConnected) { + task.update('connecting'); + // A background Shortcut must not enable the UI's high-rate live streams. + await openSession(foreground: !_background); + } + if (task.stopped || !engine.isConnected) return SyncReport(0, 0, false); + final report = await _kickSyncBurst(kickFirst: _syncBurst == null); + if (report.records > 0) _deriveScheduler.markStoredData(); + if (!_disposed) notifyListeners(); + return report; + } + /// The ONE place the band's HIGH_FREQ_SYNC prompt is programmed. Two /// requesters, one decision (`BandPromptPolicy`): the smart-wake window /// (61 s, ahead of an alarm) and, on iOS while backgrounded, the 15-min diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index 8d7537425..9de7576e7 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -77,7 +77,7 @@ Future handleHeadlessAlarmEvent(int id) async { } /// Load the local profile (no Provider in the headless isolate). -Future _loadProfile() async { +Future loadHeadlessProfile() async { try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString('local_profile_json'); @@ -88,6 +88,102 @@ Future _loadProfile() async { } } +/// Every headless caller must use the same commit-before-ACK persistence path. +BleEngine createHeadlessSyncEngine({ + required PairedDevice paired, + void Function(int records)? onCommitted, + void Function(Object error)? onCommitError, +}) { + late final BandHost bandHost; + final engine = BleEngine( + // EVERY write below is gated on [ResetGate]: a reset can begin at any + // point during a drain, and these callbacks are the reason it is not + // enough for AppState to guard only its own. Refusing loses nothing — + // none of it has been ACKed, so it is all still on the band. + onRecord: (sample, raw) async { + if (ResetGate.active) return; + await LocalDb.insertRecord(raw, sample); + }, + onState: (_) {}, + // This path drains exactly the one paired band (PairedDevice.load()), + // so kPrimaryDeviceId is the correct value here, not a placeholder. + onEvent: (id, ts, hex) async { + if (ResetGate.active) return; + await LocalDb.insertEvent(id, ts, hex, + deviceId: LocalDb.kPrimaryDeviceId); + await handleHeadlessAlarmEvent(id); + }, + log: (l) => debugPrint('[bgsync] $l'), + onRecordsBatch: (raws, samples) async { + if (ResetGate.active) return; + await LocalDb.insertRecordsBatch(raws, samples); + }, + // Routed through BandHost (M1a) rather than calling + // LocalDb.commitSyncBatch directly — same durable commit, same + // arguments, one extra await frame, and the SAME failure contract: + // `commitNativeBatch` rethrows so `DrainController.commit` still reads + // durability from a throw and `TrimAckPolicy` still blocks the ACK. + onCommitBatch: (raws, samples, trimTokenHex, + {archives, ecgRawPackets, deviceFamily}) async { + try { + // THROWS, never silently succeeds. This is the ACK gate: only + // `onCommit` can bank raws + archives + trim cursor in one + // transaction, and DrainController reads durability FROM A THROW + // (see its safe-trim invariant). Returning quietly here would tell + // the drain the chunk was banked, it would ACK, and the band would + // trim flash that this reset refused to store — turning a race into + // real data loss on a band the user may not be deleting after all if + // the reset then fails. A throw blocks the ACK and the records stay + // on the strap. + if (ResetGate.active) { + throw StateError('data reset in progress — refusing to commit'); + } + await bandHost.commitNativeBatch(raws, samples, trimTokenHex, + archives: archives, + ecgRawPackets: ecgRawPackets, + deviceFamily: deviceFamily); + } catch (e) { + onCommitError?.call(e); + rethrow; + } + onCommitted?.call(samples.length); + }, + onArchiveRecord: (raw) async { + if (ResetGate.active) return; + await LocalDb.archiveRawRecord(raw); + }, + // A WHOOP MG left generating by a dead process must be cleaned up + // BEFORE this drainer claims history — same rule as the foreground + // engine, controller-free. + onReadyEcgRecovery: (e) => ecgRecoverRetainedGuard( + guard: PrefsEcgGuardStore(), + serial: paired.serial, + cleanup: () async { + final out = await e.ecgRecoveryCleanup(); + return EcgCommandListResult([ + for (final o in out) + EcgMemberOutcome(o.label, + written: o.written, succeeded: o.succeeded), + ]); + }, + log: (l) => debugPrint('[bgsync] $l'), + ), + cursorReader: (base) => + LocalDb.getCursorInt(LocalDb.cursorKeyFor(base, LocalDb.kPrimaryDeviceId)), + // Mark this as the background drainer: if the foreground app engine already + // owns the band (same process — iOS restore-wake OR Android headless boot / + // foreground service), this engine YIELDS instead of opening a second drain + // that would double-ACK the same offload and stall the trim cursor. + isBackgroundDrainer: true, + ); + bandHost = BandHost( + adapter: WhoopFramedAdapter(engine, kWhoopGen4), + deviceId: LocalDb.kPrimaryDeviceId, + onLog: (msg) => debugPrint('[bgsync][COMMIT] $msg'), + ); + return engine; +} + /// One headless LOCAL drain pass. Safe to call from a background isolate. Never /// throws. Connects-by-id if reachable, drains whatever the band buffered to /// flash into local storage (non-destructive cursor — catches up everything since @@ -123,91 +219,7 @@ Future runHeadlessSync({BandLease? lease}) async { return true; } - // Connect → drain → store. No live streams (battery): in and out. - // `bandHost` is `late final`: the closure below captures the variable, - // not a value, so it is fine that it is only assigned after `engine` - // (whose facade adapter needs `engine` itself) is constructed. - late final BandHost bandHost; - final engine = BleEngine( - // EVERY write below is gated on [ResetGate]: a reset can begin at any - // point during a drain, and these callbacks are the reason it is not - // enough for AppState to guard only its own. Refusing loses nothing — - // none of it has been ACKed, so it is all still on the band. - onRecord: (sample, raw) async { - if (ResetGate.active) return; - await LocalDb.insertRecord(raw, sample); - }, - onState: (_) {}, - // This path drains exactly the one paired band (PairedDevice.load()), - // so kPrimaryDeviceId is the correct value here, not a placeholder. - onEvent: (id, ts, hex) async { - if (ResetGate.active) return; - await LocalDb.insertEvent(id, ts, hex, - deviceId: LocalDb.kPrimaryDeviceId); - await handleHeadlessAlarmEvent(id); - }, - log: (l) => debugPrint('[bgsync] $l'), - onRecordsBatch: (raws, samples) async { - if (ResetGate.active) return; - await LocalDb.insertRecordsBatch(raws, samples); - }, - // Routed through BandHost (M1a) rather than calling - // LocalDb.commitSyncBatch directly — same durable commit, same - // arguments, one extra await frame, and the SAME failure contract: - // `commitNativeBatch` rethrows so `DrainController.commit` still reads - // durability from a throw and `TrimAckPolicy` still blocks the ACK. - onCommitBatch: (raws, samples, trimTokenHex, - {archives, ecgRawPackets, deviceFamily}) async { - // THROWS, never silently succeeds. This is the ACK gate: only - // `onCommit` can bank raws + archives + trim cursor in one - // transaction, and DrainController reads durability FROM A THROW - // (see its safe-trim invariant). Returning quietly here would tell - // the drain the chunk was banked, it would ACK, and the band would - // trim flash that this reset refused to store — turning a race into - // real data loss on a band the user may not be deleting after all if - // the reset then fails. A throw blocks the ACK and the records stay - // on the strap. - if (ResetGate.active) { - throw StateError('data reset in progress — refusing to commit'); - } - return bandHost.commitNativeBatch(raws, samples, trimTokenHex, - archives: archives, - ecgRawPackets: ecgRawPackets, - deviceFamily: deviceFamily); - }, - onArchiveRecord: (raw) async { - if (ResetGate.active) return; - await LocalDb.archiveRawRecord(raw); - }, - // A WHOOP MG left generating by a dead process must be cleaned up - // BEFORE this drainer claims history — same rule as the foreground - // engine, controller-free. - onReadyEcgRecovery: (e) => ecgRecoverRetainedGuard( - guard: PrefsEcgGuardStore(), - serial: paired.serial, - cleanup: () async { - final out = await e.ecgRecoveryCleanup(); - return EcgCommandListResult([ - for (final o in out) - EcgMemberOutcome(o.label, - written: o.written, succeeded: o.succeeded), - ]); - }, - log: (l) => debugPrint('[bgsync] $l'), - ), - cursorReader: (base) => - LocalDb.getCursorInt(LocalDb.cursorKeyFor(base, LocalDb.kPrimaryDeviceId)), - // Mark this as the background drainer: if the foreground app engine already - // owns the band (same process — iOS restore-wake OR Android headless boot / - // foreground service), this engine YIELDS instead of opening a second drain - // that would double-ACK the same offload and stall the trim cursor. - isBackgroundDrainer: true, - ); - bandHost = BandHost( - adapter: WhoopFramedAdapter(engine, kWhoopGen4), - deviceId: LocalDb.kPrimaryDeviceId, - onLog: (msg) => debugPrint('[bgsync][COMMIT] $msg'), - ); + final engine = createHeadlessSyncEngine(paired: paired); // connect() subscribes → SET_CLOCK → INIT, so the historical offload is already // streaming when this returns. We then await it reaching HISTORY_COMPLETE. @@ -319,7 +331,7 @@ Future runHeadlessSync({BandLease? lease}) async { await DerivationEngine( log: (l) => debugPrint('[bgsync-derive] $l'), background: true, - ).run(await _loadProfile()); + ).run(await loadHeadlessProfile()); } catch (e) { debugPrint('[bgsync] derive skipped: $e'); } diff --git a/lib/sync/ios_shortcut_sync.dart b/lib/sync/ios_shortcut_sync.dart new file mode 100644 index 000000000..902845150 --- /dev/null +++ b/lib/sync/ios_shortcut_sync.dart @@ -0,0 +1,280 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../ble/ble_engine.dart'; +import '../ble/ble_state.dart'; +import '../compute/derivation_engine.dart'; +import '../data/db.dart'; +import '../data/local_repository_impl.dart'; +import '../state/prefs.dart'; +import '../widget/widget_service.dart'; +import 'background_sync.dart'; +import 'band_ownership.dart'; +import 'headless_gate.dart'; +import 'paired_device.dart'; +import 'reset_gate.dart'; +import 'shortcut_sync_task.dart'; + +class IosShortcutSync { + static const channel = MethodChannel('openstrap/shortcut_sync'); + static ShortcutSyncTask? _active; + static void Function()? _onForegroundCommitFailure; + + static void foregroundCommitFailed() => _onForegroundCommitFailure?.call(); + + static Future Function(ShortcutSyncTask)? foregroundSync; + static BleEngine? Function()? foregroundEngine; + + static Future init() async { + if (!Platform.isIOS) return; + channel.setMethodCallHandler((call) async { + final args = (call.arguments as Map?) ?? const {}; + switch (call.method) { + case 'run': + final id = args['id'] as String; + final milliseconds = (args['budgetMs'] as int).clamp(1, 600000); + return (await run(id, Duration(milliseconds: milliseconds))).toMap(); + case 'cancel': + if (_active?.id == args['id']) _active?.stop('cancelled'); + return null; + default: + throw MissingPluginException(); + } + }); + await channel.invokeMethod('ready'); + } + + static Future run(String id, Duration budget) async { + if (_active != null) return const ShortcutSyncResult('alreadyRunning'); + final task = ShortcutSyncTask( + id, + budget, + onProgress: (progress) { + unawaited( + channel + .invokeMethod('progress', progress) + .catchError( + (Object error) => + debugPrint('[shortcut-sync] progress: $error'), + ), + ); + }, + ); + _active = task; + final work = () async { + try { + return await HeadlessSyncGate.tryRun('shortcut', () => _sync(task)) ?? + const ShortcutSyncResult('alreadyRunning'); + } catch (e, st) { + debugPrint('[shortcut-sync] failed: $e\n$st'); + return ShortcutSyncResult('failed', records: task.records); + } finally { + task.onStop = null; + if (identical(_active, task)) _active = null; + } + }(); + return task.waitFor(work); + } + + static ShortcutSyncResult? _blockerResult(BleBlocker? blocker) { + if (blocker == null) return null; + return ShortcutSyncResult( + blocker == BleBlocker.permissionDenied + ? 'permissionDenied' + : 'bluetoothUnavailable', + ); + } + + static Future _sync(ShortcutSyncTask task) async { + if (ResetGate.active) throw StateError('data reset in progress'); + final paired = await PairedDevice.load(); + if (paired == null) return const ShortcutSyncResult('notPaired'); + final prefs = await SharedPreferences.getInstance(); + // AccessorySetupKit provisioning requires that no Bluetooth central is created yet. + if (prefs.getBool(Prefs.kAskAddPendingKey) ?? false) { + return const ShortcutSyncResult('setupRequired'); + } + if (task.stopped) return task.expired; + + final adapter = await FlutterBluePlus.adapterState + .firstWhere( + (s) => + s != BluetoothAdapterState.unknown && + s != BluetoothAdapterState.turningOn, + ) + .timeout(const Duration(seconds: 3)); + final blocked = _blockerResult( + classifyBleBlocker(adapterState: adapter.name), + ); + if (blocked != null) return blocked; + if (task.stopped) return task.expired; + if (ResetGate.active) throw StateError('data reset in progress'); + + final liveSync = foregroundSync; + final liveEngine = foregroundEngine?.call(); + if (liveSync != null && liveEngine != null) { + _onForegroundCommitFailure = () => task.stop('failed'); + task.update(liveEngine.isConnected ? 'syncing' : 'connecting'); + var radioConnected = liveEngine.isConnected; + final radio = BluetoothDevice.fromId(paired.remoteId).connectionState + .listen((state) { + if (state == BluetoothConnectionState.connected) { + radioConnected = true; + if (task.phase == 'connecting') task.update('initializing'); + } + }); + var lastBatches = liveEngine.offloadSnapshot['batches_acked'] as int; + final progress = Timer.periodic(const Duration(seconds: 1), (_) { + final batches = liveEngine.offloadSnapshot['batches_acked'] as int; + if (liveEngine.isConnected && + (task.phase == 'connecting' || task.phase == 'initializing')) { + task.update('syncing'); + } + if (batches != lastBatches) { + final delta = batches >= lastBatches + ? batches - lastBatches + : batches; + lastBatches = batches; + task.update(task.phase, batches: task.batches + delta); + } + }); + task.onStop = progress.cancel; + try { + // A cancelled Shortcut must not tear down the app's own live session. + final report = await liveSync(task); + if (task.stopped) return task.expired; + final blocker = _blockerResult(liveEngine.bluetoothBlocker); + if (blocker != null) return blocker; + if (!liveEngine.isConnected && report.records == 0) { + return ShortcutSyncResult( + radioConnected ? 'failed' : 'bandUnreachable', + ); + } + task.records = report.records; + if (!report.complete || await _backlogRemains(liveEngine)) { + return ShortcutSyncResult('partial', records: report.records); + } + return await _derive(task); + } finally { + _onForegroundCommitFailure = null; + task.onStop = null; + progress.cancel(); + await radio.cancel(); + } + } + + final lease = BandOwnership.tryAcquireHeadless(); + if (lease == null) return const ShortcutSyncResult('alreadyRunning'); + try { + return await _headless(task, paired); + } finally { + BandOwnership.release(lease); + } + } + + static Future _headless( + ShortcutSyncTask task, + PairedDevice paired, + ) async { + final engine = createHeadlessSyncEngine( + paired: paired, + onCommitted: (count) => task.update( + 'syncing', + records: task.records + count, + batches: task.batches + 1, + ), + onCommitError: (_) => task.stop('failed'), + ); + task.onStop = () { + // Keep the gate and lease until the engine's serialized teardown completes. + unawaited( + engine.disconnect().catchError( + (Object error) => debugPrint('[shortcut-sync] teardown: $error'), + ), + ); + }; + var radioConnected = false; + final radio = BluetoothDevice.fromId(paired.remoteId).connectionState + .listen((state) { + if (state == BluetoothConnectionState.connected) { + radioConnected = true; + if (task.phase == 'connecting') task.update('initializing'); + } + }); + try { + task.update('connecting'); + final connected = await engine.connectToRemoteId( + paired.remoteId, + generationHint: paired.generation, + ); + if (task.stopped) return task.expired; + final blocker = _blockerResult(engine.bluetoothBlocker); + if (blocker != null) return blocker; + if (!connected) { + if (BandOwnership.foregroundIntent) { + return const ShortcutSyncResult('alreadyRunning'); + } + return ShortcutSyncResult( + radioConnected ? 'failed' : 'bandUnreachable', + ); + } + task.update('syncing'); + var previousBatches = 0; + for (var session = 0; session < 20 && !task.stopped; session++) { + final report = await engine.runSync(timeout: task.remaining); + if (task.stopped) return task.expired; + final backlogRemains = await _backlogRemains(engine); + if (report.complete && !backlogRemains) { + await engine.disconnect(); + task.onStop = null; + return await _derive(task); + } + if (!report.complete || + report.batches <= previousBatches || + engine.historyStuckThisSession || + !engine.isConnected) { + break; + } + previousBatches = report.batches; + // HISTORY_COMPLETE can end one session while the advertised backlog still remains. + if (!task.stopped) await engine.requestHistorySync(); + } + return ShortcutSyncResult('partial', records: task.records); + } finally { + task.onStop = null; + try { + await engine.disconnect(); + } finally { + await radio.cancel(); + } + } + } + + static Future _derive(ShortcutSyncTask task) async { + if (task.stopped) return task.expired; + task.update('processing'); + final profile = await loadHeadlessProfile(); + if (task.stopped) return task.expired; + if (ResetGate.active) throw StateError('data reset in progress'); + await DerivationEngine( + log: (line) => debugPrint('[shortcut-derive] $line'), + background: true, + ).run(profile, heavy: false); + if (task.stopped) return task.expired; + await WidgetService.refresh( + LocalRepositoryImpl(getProfileMap: () => profile.toMap()), + ); + return ShortcutSyncResult('complete', records: task.records); + } + + static Future _backlogRemains(BleEngine engine) async { + final frontier = await LocalDb.getCursorInt('rec_ts_hw'); + final newest = engine.strapHistoryNewestTs; + return frontier != null && newest != null && newest - frontier > 300; + } +} diff --git a/lib/sync/shortcut_sync_task.dart b/lib/sync/shortcut_sync_task.dart new file mode 100644 index 000000000..244b44bed --- /dev/null +++ b/lib/sync/shortcut_sync_task.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +class ShortcutSyncResult { + final String status; + final int records; + const ShortcutSyncResult(this.status, {this.records = 0}); + + Map toMap() => {'status': status, 'records': records}; +} + +/// The caller's deadline must not release BLE ownership before cleanup finishes. +class ShortcutSyncTask { + final String id; + final Duration budget; + final void Function(Map)? onProgress; + final Stopwatch _clock = Stopwatch()..start(); + final _stopped = Completer(); + String phase = 'starting'; + int records = 0; + int batches = 0; + void Function()? onStop; + + ShortcutSyncTask(this.id, this.budget, {this.onProgress}); + + bool get stopped => _stopped.isCompleted; + Duration get remaining { + final value = budget - _clock.elapsed; + return value.isNegative ? Duration.zero : value; + } + + void update(String phase, {int? records, int? batches}) { + if (stopped) return; + this.phase = phase; + this.records = records ?? this.records; + this.batches = batches ?? this.batches; + onProgress?.call({ + 'id': id, + 'phase': phase, + 'records': this.records, + 'batches': this.batches, + }); + } + + ShortcutSyncResult get expired => ShortcutSyncResult( + phase == 'connecting' + ? 'bandUnreachable' + : phase == 'syncing' || phase == 'processing' + ? 'partial' + : 'timedOut', + records: records, + ); + + void stop([String? status]) { + if (stopped) return; + _stopped.complete( + status == null ? expired : ShortcutSyncResult(status, records: records), + ); + onStop?.call(); + } + + Future waitFor(Future work) async { + final timer = Timer(remaining, stop); + try { + return await Future.any([work, _stopped.future]); + } finally { + timer.cancel(); + } + } +} diff --git a/test/app_state_shortcut_sync_test.dart b/test/app_state_shortcut_sync_test.dart new file mode 100644 index 000000000..35f30506a --- /dev/null +++ b/test/app_state_shortcut_sync_test.dart @@ -0,0 +1,175 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/sync/reset_gate.dart'; +import 'package:openstrap_edge/sync/shortcut_sync_task.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _ConnectedEngine extends BleEngine { + final reply = Completer(); + int requests = 0; + int runs = 0; + int disconnects = 0; + int probes = 0; + Duration quietFor = Duration.zero; + + _ConnectedEngine() : super(onRecord: (_, _) async {}, onState: (_) {}); + + @override + bool get isConnected => true; + + @override + Duration get sinceLastRx => quietFor; + + @override + Future probeLink({ + Duration timeout = const Duration(seconds: 3), + }) async { + probes++; + return true; + } + + @override + Future requestHistorySync() async => requests++; + + @override + Future runSync({ + Duration timeout = const Duration(seconds: 600), + }) { + runs++; + return reply.future; + } + + @override + Future disconnect() async => disconnects++; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_app_state_shortcut_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + test( + 'a data reset prevents a Shortcut from touching the app-owned band', + () async { + final engine = _ConnectedEngine(); + engine.reply.complete(SyncReport(0, 0, true)); + final app = AppState.forTesting(engine: engine)..initialized = true; + addTearDown(app.dispose); + addTearDown(ResetGate.resetForTest); + ResetGate.enter(); + await expectLater( + app.syncForShortcut( + ShortcutSyncTask('reset', const Duration(seconds: 5)), + ), + throwsStateError, + ); + expect(engine.requests, 0); + expect(engine.disconnects, 0); + }, + ); + + test('a quiet non-streaming link is probed and reused', () async { + final engine = _ConnectedEngine()..quietFor = const Duration(minutes: 5); + final app = AppState.forTesting(engine: engine)..initialized = true; + addTearDown(app.dispose); + engine.reply.complete(SyncReport(0, 0, true)); + final report = await app.syncForShortcut( + ShortcutSyncTask('quiet', const Duration(seconds: 5)), + ); + expect(report.complete, isTrue); + expect(engine.probes, 1); + expect(engine.disconnects, 0); + expect(engine.requests, 1); + }); + + test('concurrent callers join the app-owned sync burst', () async { + final engine = _ConnectedEngine(); + final app = AppState.forTesting(engine: engine)..initialized = true; + addTearDown(app.dispose); + final first = app.syncForShortcut( + ShortcutSyncTask('first', const Duration(seconds: 5)), + ); + final second = app.syncForShortcut( + ShortcutSyncTask('second', const Duration(seconds: 5)), + ); + while (engine.runs == 0) { + await Future.delayed(const Duration(milliseconds: 5)); + } + expect(engine.requests, 1); + expect(engine.runs, 1); + engine.reply.complete(SyncReport(0, 0, true)); + expect((await first).complete, isTrue); + expect((await second).complete, isTrue); + expect(engine.disconnects, 0); + }); + + test( + 'cancellation while waiting for initialization never touches the band', + () async { + final engine = _ConnectedEngine(); + final app = AppState.forTesting(engine: engine); + addTearDown(app.dispose); + final task = ShortcutSyncTask('starting', const Duration(seconds: 5)); + final work = app.syncForShortcut(task); + task.stop('cancelled'); + expect((await work).complete, isFalse); + expect(engine.requests, 0); + expect(engine.disconnects, 0); + }, + ); + + test( + 'cancellation while the app is busy never starts another burst', + () async { + final engine = _ConnectedEngine(); + final app = AppState.forTesting(engine: engine) + ..initialized = true + ..busy = true; + addTearDown(app.dispose); + final task = ShortcutSyncTask('busy', const Duration(seconds: 5)); + final work = app.syncForShortcut(task); + expect(task.phase, 'waiting'); + task.stop('cancelled'); + expect((await work).complete, isFalse); + expect(engine.requests, 0); + expect(engine.disconnects, 0); + }, + ); + + test('cancelling a waiter preserves the app-owned transfer', () async { + final engine = _ConnectedEngine(); + final app = AppState.forTesting(engine: engine)..initialized = true; + addTearDown(app.dispose); + final task = ShortcutSyncTask('cancel', const Duration(seconds: 5)); + final work = app.syncForShortcut(task); + while (engine.runs == 0) { + await Future.delayed(const Duration(milliseconds: 5)); + } + task.stop('cancelled'); + expect(engine.disconnects, 0); + engine.reply.complete(SyncReport(0, 0, true)); + await work; + expect(engine.disconnects, 0); + expect(task.stopped, isTrue); + }); +} diff --git a/test/ios_shortcut_sync_test.dart b/test/ios_shortcut_sync_test.dart new file mode 100644 index 000000000..07d73109b --- /dev/null +++ b/test/ios_shortcut_sync_test.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/state/prefs.dart'; +import 'package:openstrap_edge/sync/background_sync.dart'; +import 'package:openstrap_edge/sync/band_ownership.dart'; +import 'package:openstrap_edge/sync/headless_gate.dart'; +import 'package:openstrap_edge/sync/ios_shortcut_sync.dart'; +import 'package:openstrap_edge/sync/paired_device.dart'; +import 'package:openstrap_edge/sync/reset_gate.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_shortcut_sync_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await LocalDb.deleteDevice(); + HeadlessSyncGate.resetForTest(); + BandOwnership.resetForTest(); + }); + + test( + 'reset is a failure, not an ignorable pairing or connectivity result', + () async { + addTearDown(ResetGate.resetForTest); + ResetGate.enter(); + final result = await IosShortcutSync.run( + 'reset', + const Duration(seconds: 2), + ); + expect(result.status, 'failed'); + expect(HeadlessSyncGate.busy, isFalse); + expect(BandOwnership.owner, isNull); + }, + ); + + test( + 'shared headless commit refuses ACK during reset and reports failure', + () async { + addTearDown(ResetGate.resetForTest); + var committed = 0; + Object? failure; + final engine = createHeadlessSyncEngine( + paired: PairedDevice('test', 'serial'), + onCommitted: (_) => committed++, + onCommitError: (error) => failure = error, + ); + ResetGate.enter(); + await expectLater( + engine.onCommitBatch!([], [], '0011223344556677'), + throwsStateError, + ); + expect(failure, isA()); + expect(committed, 0); + expect(engine.onReadyEcgRecovery, isNotNull); + }, + ); + + test('shared headless event callback retains alarm confirmation', () async { + final engine = createHeadlessSyncEngine( + paired: PairedDevice('test', 'serial'), + ); + final onEvent = engine.onEvent! as Future Function(int, int, String); + await onEvent(56, 1, ''); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('alarm_epoch_confirmed'), isTrue); + await prefs.setBool('alarm_epoch_confirmed', false); + addTearDown(ResetGate.resetForTest); + ResetGate.enter(); + await onEvent(56, 2, ''); + expect(prefs.getBool('alarm_epoch_confirmed'), isFalse); + }); + + test( + 'unpaired invocation reports a prerequisite failure without touching Bluetooth', + () async { + final result = await IosShortcutSync.run( + 'unpaired', + const Duration(seconds: 2), + ); + expect(result.toMap(), {'status': 'notPaired', 'records': 0}); + expect(HeadlessSyncGate.busy, isFalse); + expect(BandOwnership.owner, isNull); + }, + ); + + test('pending accessory setup must not create a Bluetooth central', () async { + await PairedDevice.save( + '00000000-0000-0000-0000-000000000001', + 'test', + generation: 'gen4', + ); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(Prefs.kAskAddPendingKey, true); + final result = await IosShortcutSync.run( + 'setup', + const Duration(seconds: 2), + ); + expect(result.status, 'setupRequired'); + expect(HeadlessSyncGate.busy, isFalse); + expect(BandOwnership.owner, isNull); + }); + + test( + 'another wake owns the gate and the Shortcut does not run or claim success', + () async { + final release = Completer(); + final wake = HeadlessSyncGate.tryRun('bg_task', () => release.future); + final result = await IosShortcutSync.run( + 'overlap', + const Duration(seconds: 2), + ); + expect(result.status, 'alreadyRunning'); + expect(HeadlessSyncGate.busy, isTrue); + release.complete(); + await wake; + expect(HeadlessSyncGate.busy, isFalse); + expect( + (await IosShortcutSync.run('next', const Duration(seconds: 2))).status, + 'notPaired', + ); + }, + ); +} diff --git a/test/reset_quiesces_ingest_test.dart b/test/reset_quiesces_ingest_test.dart index 0d01e6641..fba6fdc55 100644 --- a/test/reset_quiesces_ingest_test.dart +++ b/test/reset_quiesces_ingest_test.dart @@ -132,7 +132,7 @@ void main() { expect(at, greaterThanOrEqualTo(0), reason: '$cb not found'); // Slice to the NEXT callback at the same indent, so a guard belonging to // a neighbour can never be mistaken for this one's. - final next = bg.indexOf('\n on', at + cb.length); + final next = bg.indexOf('\n on', at + cb.length); final window = bg.substring(at, next < 0 ? bg.length : next); expect(window.contains('ResetGate.active'), isTrue, reason: '$cb writes without consulting ResetGate'); diff --git a/test/shortcut_sync_task_test.dart b/test/shortcut_sync_task_test.dart new file mode 100644 index 000000000..c0c8e0808 --- /dev/null +++ b/test/shortcut_sync_task_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/sync/headless_gate.dart'; +import 'package:openstrap_edge/sync/shortcut_sync_task.dart'; + +void main() { + setUp(HeadlessSyncGate.resetForTest); + + test('returns completed work without cancelling it', () async { + final task = ShortcutSyncTask('one', const Duration(seconds: 1)); + var stopped = false; + task.onStop = () => stopped = true; + final result = await task.waitFor( + Future.value(const ShortcutSyncResult('complete', records: 12)), + ); + expect(result.toMap(), {'status': 'complete', 'records': 12}); + expect(stopped, isFalse); + }); + + test( + 'a connection deadline is distinguishable from a partial drain', + () async { + for (final phase in [ + 'starting', + 'initializing', + 'connecting', + 'syncing', + 'processing', + ]) { + final task = ShortcutSyncTask('one', Duration.zero) + ..update(phase, records: 9); + final result = await task.waitFor( + Completer().future, + ); + expect(result.status, switch (phase) { + 'starting' || 'initializing' => 'timedOut', + 'connecting' => 'bandUnreachable', + _ => 'partial', + }); + expect(result.records, 9); + } + }, + ); + + test( + 'cancellation stops once and does not report subsequent progress', + () async { + final updates = >[]; + final task = ShortcutSyncTask( + 'one', + const Duration(seconds: 1), + onProgress: updates.add, + ); + var stops = 0; + task.onStop = () => stops++; + task.update('syncing', records: 15, batches: 2); + final result = task.waitFor(Completer().future); + task.stop('cancelled'); + task.stop('failed'); + task.update('syncing', records: 40); + expect((await result).status, 'cancelled'); + expect(stops, 1); + expect(updates, [ + {'id': 'one', 'phase': 'syncing', 'records': 15, 'batches': 2}, + ]); + }, + ); + + test( + 'deadline returns without releasing the ownership gate during cleanup', + () async { + final cleanup = Completer(); + final task = ShortcutSyncTask('one', Duration.zero)..update('syncing'); + final work = HeadlessSyncGate.tryRun('shortcut', () => cleanup.future); + final result = await task.waitFor(work.then((value) => value!)); + expect(result.status, 'partial'); + expect(HeadlessSyncGate.busy, isTrue); + var secondRan = false; + expect( + await HeadlessSyncGate.tryRun('other', () async => secondRan = true), + isNull, + ); + expect(secondRan, isFalse); + cleanup.complete(const ShortcutSyncResult('partial')); + await work; + expect(HeadlessSyncGate.busy, isFalse); + }, + ); + + test('unexpected failures are not converted to connectivity skips', () async { + final task = ShortcutSyncTask('one', const Duration(seconds: 1)); + final work = Future.error(StateError('storage failed')); + await expectLater(task.waitFor(work), throwsStateError); + }); +} From 0b47c7c366ef8fb4ed05643d9649339dc7660582 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:30:47 +0530 Subject: [PATCH 2/9] shortcut sync: rearm restore after failed bg connect, stop headless loop only on zero acks, deadline in derive is complete --- lib/state/app_state.dart | 5 ++++- lib/sync/ios_shortcut_sync.dart | 5 ++--- lib/sync/shortcut_sync_task.dart | 5 ++++- test/shortcut_sync_task_test.dart | 1 + 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 4a057a653..c49830e29 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -5469,7 +5469,10 @@ class AppState extends ChangeNotifier { if (!engine.isConnected) { task.update('connecting'); // A background Shortcut must not enable the UI's high-rate live streams. - await openSession(foreground: !_background); + final background = _background; + await openSession(foreground: !background); + // openSession left foregroundActive set; re-arm or restore wakes stay ignored. + if (background && !engine.isConnected) await _armRecovery(); } if (task.stopped || !engine.isConnected) return SyncReport(0, 0, false); final report = await _kickSyncBurst(kickFirst: _syncBurst == null); diff --git a/lib/sync/ios_shortcut_sync.dart b/lib/sync/ios_shortcut_sync.dart index 902845150..ff340188f 100644 --- a/lib/sync/ios_shortcut_sync.dart +++ b/lib/sync/ios_shortcut_sync.dart @@ -224,7 +224,6 @@ class IosShortcutSync { ); } task.update('syncing'); - var previousBatches = 0; for (var session = 0; session < 20 && !task.stopped; session++) { final report = await engine.runSync(timeout: task.remaining); if (task.stopped) return task.expired; @@ -235,12 +234,11 @@ class IosShortcutSync { return await _derive(task); } if (!report.complete || - report.batches <= previousBatches || + report.batches == 0 || engine.historyStuckThisSession || !engine.isConnected) { break; } - previousBatches = report.batches; // HISTORY_COMPLETE can end one session while the advertised backlog still remains. if (!task.stopped) await engine.requestHistorySync(); } @@ -266,6 +264,7 @@ class IosShortcutSync { background: true, ).run(profile, heavy: false); if (task.stopped) return task.expired; + if (ResetGate.active) throw StateError('data reset in progress'); await WidgetService.refresh( LocalRepositoryImpl(getProfileMap: () => profile.toMap()), ); diff --git a/lib/sync/shortcut_sync_task.dart b/lib/sync/shortcut_sync_task.dart index 244b44bed..8c3b5bab0 100644 --- a/lib/sync/shortcut_sync_task.dart +++ b/lib/sync/shortcut_sync_task.dart @@ -44,7 +44,10 @@ class ShortcutSyncTask { ShortcutSyncResult get expired => ShortcutSyncResult( phase == 'connecting' ? 'bandUnreachable' - : phase == 'syncing' || phase == 'processing' + // the transfer is already committed once derive starts + : phase == 'processing' + ? 'complete' + : phase == 'syncing' ? 'partial' : 'timedOut', records: records, diff --git a/test/shortcut_sync_task_test.dart b/test/shortcut_sync_task_test.dart index c0c8e0808..be5ab1d11 100644 --- a/test/shortcut_sync_task_test.dart +++ b/test/shortcut_sync_task_test.dart @@ -36,6 +36,7 @@ void main() { expect(result.status, switch (phase) { 'starting' || 'initializing' => 'timedOut', 'connecting' => 'bandUnreachable', + 'processing' => 'complete', _ => 'partial', }); expect(result.records, 9); From 1d429e00573da81ba161f7c7dffee9bf9bdd8690 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:30:47 +0530 Subject: [PATCH 3/9] trim shortcuts guide --- guides/IOS_SHORTCUTS.md | 51 +++++++++++------------------------------ 1 file changed, 13 insertions(+), 38 deletions(-) diff --git a/guides/IOS_SHORTCUTS.md b/guides/IOS_SHORTCUTS.md index 8f152af87..2acca3a0a 100644 --- a/guides/IOS_SHORTCUTS.md +++ b/guides/IOS_SHORTCUTS.md @@ -2,39 +2,28 @@ ## Sync Data -On iOS 16 and later, add **Edge → Sync Data** to a shortcut. Pair your band in Edge and grant Bluetooth access before using an unattended automation. The action runs without opening Edge's interface. It starts a real band sync, rather than scheduling a discretionary background refresh or simulating a refresh gesture. +On iOS 16 and later, add **Edge → Sync Data** to a shortcut. Pair your band in Edge and allow Bluetooth first. The action syncs the band without opening the app. -Expand the action to enable **Ignore Connectivity Errors**. It is off by default. When enabled, Bluetooth being unavailable or the paired band being unreachable returns a successful text result beginning with `Skipped:` instead of throwing an action error. Missing pairing, denied Bluetooth permission, startup failures, and other failures are not suppressed. The action does not display a dialog or post a notification of its own. This option cannot disable notifications or progress UI that iOS or Shortcuts independently chooses to display. +**Ignore Connectivity Errors** (off by default): when Bluetooth is off or the band is out of range, the action returns a `Skipped:` result instead of an error. Pairing, permission and other errors are still reported. -For a personal automation, choose a Time of Day trigger, select **Run Immediately** where offered, and run the shortcut. Configure the automation's own notification options separately. Several daily triggers can provide several sync opportunities; they do not guarantee that the band is reachable or that iOS will finish every invocation. +For a personal automation, pick a Time of Day trigger and **Run Immediately**. Several triggers a day give several chances to sync; none of them guarantee the band is in range. -### Results - -- **Band data synchronized:** the band transfer completed and Edge requested light processing and refreshed its widget snapshot. An existing derivation takes precedence. This is not a claim that every historical day has received full heavy analysis or that HealthKit export completed. -- **Sync is incomplete:** the invocation ran out of time or the transfer ended early. Previously saved data is retained. Run the action again or open Edge for a longer catch-up. -- **A sync request is already active:** another request owns the sync path. No competing transfer was started. -- **Skipped:** one of the two opted-out connectivity failures occurred; no successful sync is claimed. - -The ordinary action has a 25-second native deadline, including Flutter startup. The Dart transfer receives a slightly shorter budget so it can report partial progress before that deadline. An iOS interruption can still prevent a result from being returned. Apple's ordinary App Intent execution budget is approximately 30 seconds; see [LongRunningIntent](https://developer.apple.com/documentation/appintents/longrunningintent). - -## Lifecycle and data safety - -For an interactive fallback, **Open Edge and Sync** brings the app forward and invokes the same sync bridge. It is not intended for unattended locked-device automations. The action itself remains bounded, but an app-owned session can keep catching up while Edge is open. +The action has about 25 seconds, including app startup. A large backlog can need more than one run, or open Edge to catch up. -The application retains one headless-capable Flutter engine. Foreground scenes attach to that same engine, keeping the UI, background wakes, and Shortcuts in one isolate with the same band-ownership guards. The native bridge waits for an explicit Dart readiness handshake, correlates replies with requests, and ignores late replies after cancellation. - -An existing app-owned connection and sync burst are reused. Otherwise the action takes the existing headless gate and band lease and uses the same `BleEngine` and `BandHost` persistence callbacks as normal background sync. Records and the durable cursor are committed before a batch is acknowledged to the band. +### Results -Cancellation or a deadline stops an action-owned connection. Its gate and lease remain held until serialized BLE cleanup finishes. Cancelling a Shortcut does not disconnect an independent app-owned live session; that session can continue its normal synchronization. A Shortcut never pretends to finish merely because it started asynchronous work. +- **Band data synchronized:** the transfer finished and light processing was started. +- **Sync is incomplete:** it ran out of time or the transfer stopped early. Saved data is kept; run it again. +- **A sync request is already active:** another sync owns the band, so nothing new was started. +- **Skipped:** Bluetooth was unavailable or the band was unreachable, with the option on. -## Validation +## Open Edge and Sync -The Dart task tests cover completion, deadline classification, cancellation, progress suppression after cancellation, and ownership retention during cleanup. The iOS Runner tests cover bridge readiness, concurrent requests, late replies, cancellation, malformed responses, and the exact connectivity-error allowlist. A clean-simulator integration test calls through the real Flutter bridge and expects a missing-pairing error rather than success. +Brings the app forward and runs the same sync. Use it interactively, not from locked-phone automations. -The separate **ShortcutIntents** Xcode scheme uses Apple's `AppIntentsTesting` framework on iOS 27 to exercise background invocation, relaunch after termination, and the foreground fallback through the system's intent infrastructure. It does not replace the ordinary **Runner** scheme or raise the application's deployment target. Use a clean simulator and a separate bundle identifier so these tests cannot use a real pairing or change an existing installation: +## Native tests ```sh -flutter pub get flutter build ios --simulator --debug cd ios xcodebuild -workspace Runner.xcworkspace -scheme Runner \ @@ -44,18 +33,4 @@ xcodebuild -workspace Runner.xcworkspace -scheme Runner \ APP_GROUP_IDENTIFIER=group.com.example.openstrap.shortcuttests test ``` -For the system-invocation tests, use Xcode 27 with an iOS 27 simulator and replace `-scheme Runner -only-testing:RunnerTests` with `-scheme ShortcutIntents`. Unlike the native Runner tests, `AppIntentsTesting` requires the app and UI test runner to be development-signed by the same team. Replace `CODE_SIGNING_ALLOWED=NO` with `CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY="Apple Development" DEVELOPMENT_TEAM="$TEAM_ID" APPLE_DEVELOPMENT_TEAM="$TEAM_ID"`, where `TEAM_ID` is your Apple development team. See [Apple's AppIntentsTesting walkthrough](https://developer.apple.com/videos/play/wwdc2026/295/). - -Simulator tests do not establish real Bluetooth transfer reliability. Before relying on an unattended automation, test on an iPhone with a paired band: - -| Scenario | Expected behavior | -| --- | --- | -| Edge open, then suspended | The same connection is reused; no duplicate drain. | -| Edge terminated, then action invoked | Flutter starts without requiring a visible scene and the action reaches the sync service. | -| Phone locked | The action can attempt to run, subject to storage availability and iOS policy. | -| Bluetooth off or band out of range | Error with the option off; successful `Skipped:` result with it on. | -| Bluetooth permission denied | Error with either option value. | -| Large backlog or cancellation | No false completion or ACK of unsaved data; a later run can resume. | -| UI refresh overlaps the action | Only one transfer owns the band. | - -Test force-quit followed by a scheduled personal automation separately from Bluetooth state restoration. These are different launch mechanisms; neither a foreground test nor a Simulator test proves the force-quit/locked-device scheduling case. +The `ShortcutIntents` scheme runs the system-invocation tests. It needs Xcode 27, an iOS 27 simulator, and a development-signed build. From 394620a2cfe246de06b86b3b3b788257663b1827 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:35:44 +0530 Subject: [PATCH 4/9] bg task: point comments at registerEngine --- ios/Runner/BgSyncScheduler.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/Runner/BgSyncScheduler.swift b/ios/Runner/BgSyncScheduler.swift index 4601676c3..8a16f5b94 100644 --- a/ios/Runner/BgSyncScheduler.swift +++ b/ios/Runner/BgSyncScheduler.swift @@ -23,7 +23,7 @@ import Flutter /// 1. BGTaskScheduler.shared.register(forTaskIdentifier:...) in didFinishLaunching /// (BEFORE super returns) — the OS needs this call before launch completes. /// 2. handleTask(_:) wired as the task handler in that same register block. -/// 3. wireChannel(messenger:) called from didInitializeImplicitFlutterEngine so +/// 3. wireChannel(messenger:) called from AppDelegate.registerEngine so /// the Flutter binary messenger is available for Dart callout. /// 4. schedule() called after wireChannel so the first request is queued once /// Dart is ready, and again on every applicationDidEnterBackground. @@ -44,7 +44,7 @@ enum BackgroundTaskManager { // MARK: - AppDelegate hooks - /// Wire the Dart method channel. Called from didInitializeImplicitFlutterEngine + /// Wire the Dart method channel. Called from AppDelegate.registerEngine /// once the binary messenger is live. Also called from schedule() guard so we /// never schedule without a channel. static func wireChannel(messenger: FlutterBinaryMessenger) { From 0bd7cba79baf6c610b66c544f480265c341f645b Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:49:13 +0530 Subject: [PATCH 5/9] shortcut sync: rearm alarm + wake window on headless connect, report failed connects honestly, stop waiting on the app burst at deadline, skip own derive when app is resumed --- lib/state/app_state.dart | 15 ++- lib/sync/background_sync.dart | 176 +++++++++++++------------ lib/sync/ios_shortcut_sync.dart | 16 ++- lib/sync/shortcut_sync_task.dart | 1 + test/app_state_shortcut_sync_test.dart | 18 +++ test/ios_shortcut_sync_test.dart | 14 ++ test/reset_quiesces_ingest_test.dart | 9 +- 7 files changed, 157 insertions(+), 92 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index c49830e29..4b6af6562 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -5475,10 +5475,17 @@ class AppState extends ChangeNotifier { if (background && !engine.isConnected) await _armRecovery(); } if (task.stopped || !engine.isConnected) return SyncReport(0, 0, false); - final report = await _kickSyncBurst(kickFirst: _syncBurst == null); - if (report.records > 0) _deriveScheduler.markStoredData(); - if (!_disposed) notifyListeners(); - return report; + final burst = _kickSyncBurst(kickFirst: _syncBurst == null).then((report) { + if (report.records > 0) _deriveScheduler.markStoredData(); + if (!_disposed) notifyListeners(); + return report; + }); + // The burst is the app's and can run for many minutes; a stopped Shortcut + // stops waiting so it releases the headless gate, not the transfer. + return Future.any([ + burst, + task.whenStopped.then((_) => SyncReport(0, 0, false)), + ]); } /// The ONE place the band's HIGH_FREQ_SYNC prompt is programmed. Two diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index 9de7576e7..252ce8623 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -184,6 +184,99 @@ BleEngine createHeadlessSyncEngine({ return engine; } +/// Post-connect work every headless connect owes the band, whichever entry +/// point made it: pin the discovered generation, then program the HighFreq +/// wake window for the imminent alarm. +Future prepareHeadlessLink(BleEngine engine, PairedDevice paired) async { + // Pin the discovered generation onto the pairing record, exactly like the + // foreground engine-state heal does — a headless-only phone would + // otherwise re-probe the connect route on every wake forever. + final gen = engine.state.generation; + if ((gen == 'gen4' || gen == 'gen5') && gen != paired.generation) { + await PairedDevice.save(paired.remoteId, paired.serial, generation: gen); + } + // Read the schedule + currently-armed epoch for the HighFreq window + // check ONLY — HighFreqWakeWindow needs the window of the alarm that's + // imminent right now, before the (possibly long) sync runs. This + // read is NOT reused by [rearmHeadlessAlarm]: `runSync()` can + // take a while, and re-reading fresh there (as the old code did) avoids + // arming a stale schedule if the user edits it mid-sync (see PR #403). + final preSyncSchedule = fillDefaultAlarmSchedule([ + for (final r in await LocalDb.alarmScheduleRows()) + AlarmScheduleEntry.fromRow(r), + ]); + final preSyncPrefs = await SharedPreferences.getInstance(); + final armedWindow = armedSmartWakeWindow( + epoch: preSyncPrefs.getInt('alarm_epoch'), + schedule: preSyncSchedule, + ); + final plan = await HighFreqWakeWindow.planNow( + scheduledWindowEnd: armedWindow?.windowEnd, + scheduledWindowMinutes: armedWindow?.minutes ?? 0, + ); + await engine.applyHighFreqWakeWindow( + enabled: plan.shouldEnable, + targetWake: plan.targetWake, + duration: HighFreqWakeWindow.lease, + intervalSeconds: 61, // gen5 rejects <= 60 + + reason: plan.source, + ); + debugPrint( + '[bgsync] HighFreq wake window: source=${plan.source} ' + 'samples=${plan.sampleCount} enabled=${plan.shouldEnable} ' + 'target=${plan.targetWake?.toIso8601String()}', + ); +} + +/// Arms the next weekly-schedule occurrence on a headless connection. The band +/// holds one armed epoch, so every connect path has to re-arm or the day after +/// a fired alarm stays unarmed. Never throws. +Future rearmHeadlessAlarm(BleEngine engine) async { + // Feature 1's arming engine, headless half: "on every successful + // connect AND after each headless sync". No AppState here, so the + // schedule read and the `alarm_epoch` persistence go straight through + // LocalDb/SharedPreferences — the same store the foreground path uses, + // so whichever side runs next sees a consistent value. Re-read fresh + // here (not [prepareHeadlessLink]'s copies) in case the user changed the + // schedule while `runSync()` was draining. + try { + final schedule = fillDefaultAlarmSchedule([ + for (final r in await LocalDb.alarmScheduleRows()) + AlarmScheduleEntry.fromRow(r), + ]); + final prefs = await SharedPreferences.getInstance(); + final result = await armNextScheduledOccurrence( + engine: engine, + schedule: schedule, + currentArmedEpoch: prefs.getInt('alarm_epoch'), + ); + if (result.disabled) { + await prefs.remove('alarm_epoch'); + await prefs.remove('alarm_epoch_confirmed'); + } else if (result.epoch != null) { + final epoch = result.epoch!; + // No live AppState here to catch a late ALARM_SET (event 56) the way + // the foreground grace timer does, so wait for it inline — same + // grace window as AlarmConfirmation's default (6s) — before this + // headless connection closes. Not confirmed within that window still + // persists the epoch (optimistic, matching the foreground write) but + // as unconfirmed, so the 7pm safety check (AppState._alarmArmedTonight) + // won't wrongly treat an un-latched headless arm as covering tonight. + final armedAtMs = DateTime.now().millisecondsSinceEpoch; + var confirmed = false; + for (var i = 0; i < 6 && !confirmed; i++) { + await Future.delayed(const Duration(milliseconds: 1000)); + confirmed = await LocalDb.alarmSetConfirmedSince(armedAtMs); + } + await prefs.setInt('alarm_epoch', epoch); + await prefs.setBool('alarm_epoch_confirmed', confirmed); + } + } catch (e) { + debugPrint('[bgsync] alarm re-arm skipped: $e'); + } +} + /// One headless LOCAL drain pass. Safe to call from a background isolate. Never /// throws. Connects-by-id if reachable, drains whatever the band buffered to /// flash into local storage (non-destructive cursor — catches up everything since @@ -232,94 +325,15 @@ Future runHeadlessSync({BandLease? lease}) async { await checkSyncStaleness(); return true; } - // Pin the discovered generation onto the pairing record, exactly like the - // foreground engine-state heal does — a headless-only phone would - // otherwise re-probe the connect route on every wake forever. - final gen = engine.state.generation; - if ((gen == 'gen4' || gen == 'gen5') && gen != paired.generation) { - await PairedDevice.save(paired.remoteId, paired.serial, generation: gen); - } try { - // Read the schedule + currently-armed epoch for the HighFreq window - // check ONLY — HighFreqWakeWindow needs the window of the alarm that's - // imminent right now, before the (possibly long) sync below runs. This - // read is NOT reused for the re-arm block further down: `runSync()` can - // take a while, and re-reading fresh there (as the old code did) avoids - // arming a stale schedule if the user edits it mid-sync (see PR #403). - final preSyncSchedule = fillDefaultAlarmSchedule([ - for (final r in await LocalDb.alarmScheduleRows()) - AlarmScheduleEntry.fromRow(r), - ]); - final preSyncPrefs = await SharedPreferences.getInstance(); - final armedWindow = armedSmartWakeWindow( - epoch: preSyncPrefs.getInt('alarm_epoch'), - schedule: preSyncSchedule, - ); - final plan = await HighFreqWakeWindow.planNow( - scheduledWindowEnd: armedWindow?.windowEnd, - scheduledWindowMinutes: armedWindow?.minutes ?? 0, - ); - await engine.applyHighFreqWakeWindow( - enabled: plan.shouldEnable, - targetWake: plan.targetWake, - duration: HighFreqWakeWindow.lease, - intervalSeconds: 61, // gen5 rejects <= 60 - - reason: plan.source, - ); - debugPrint( - '[bgsync] HighFreq wake window: source=${plan.source} ' - 'samples=${plan.sampleCount} enabled=${plan.shouldEnable} ' - 'target=${plan.targetWake?.toIso8601String()}', - ); + await prepareHeadlessLink(engine, paired); // Await the full backlog (default timeout): a phone-free run/sleep can leave a // large offline backlog on the band's flash. We never abort — if iOS cuts the // background window short, the offload persists what it got (flush-before-ACK) // and the next wake resumes from the (now-advanced) cursor. No live streams // (battery): connect → listen → store → ACK → derive → disconnect. await engine.runSync(); - // Feature 1's arming engine, headless half: "on every successful - // connect AND after each headless sync". No AppState here, so the - // schedule read and the `alarm_epoch` persistence go straight through - // LocalDb/SharedPreferences — the same store the foreground path uses, - // so whichever side runs next sees a consistent value. Re-read fresh - // here (not the pre-sync copies above) in case the user changed the - // schedule while `runSync()` was draining. - try { - final schedule = fillDefaultAlarmSchedule([ - for (final r in await LocalDb.alarmScheduleRows()) - AlarmScheduleEntry.fromRow(r), - ]); - final prefs = await SharedPreferences.getInstance(); - final result = await armNextScheduledOccurrence( - engine: engine, - schedule: schedule, - currentArmedEpoch: prefs.getInt('alarm_epoch'), - ); - if (result.disabled) { - await prefs.remove('alarm_epoch'); - await prefs.remove('alarm_epoch_confirmed'); - } else if (result.epoch != null) { - final epoch = result.epoch!; - // No live AppState here to catch a late ALARM_SET (event 56) the way - // the foreground grace timer does, so wait for it inline — same - // grace window as AlarmConfirmation's default (6s) — before this - // headless connection closes. Not confirmed within that window still - // persists the epoch (optimistic, matching the foreground write) but - // as unconfirmed, so the 7pm safety check (AppState._alarmArmedTonight) - // won't wrongly treat an un-latched headless arm as covering tonight. - final armedAtMs = DateTime.now().millisecondsSinceEpoch; - var confirmed = false; - for (var i = 0; i < 6 && !confirmed; i++) { - await Future.delayed(const Duration(milliseconds: 1000)); - confirmed = await LocalDb.alarmSetConfirmedSince(armedAtMs); - } - await prefs.setInt('alarm_epoch', epoch); - await prefs.setBool('alarm_epoch_confirmed', confirmed); - } - } catch (e) { - debugPrint('[bgsync] alarm re-arm skipped: $e'); - } + await rearmHeadlessAlarm(engine); } finally { await engine.disconnect(); } diff --git a/lib/sync/ios_shortcut_sync.dart b/lib/sync/ios_shortcut_sync.dart index ff340188f..cdaf2e1b2 100644 --- a/lib/sync/ios_shortcut_sync.dart +++ b/lib/sync/ios_shortcut_sync.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -159,6 +159,12 @@ class IosShortcutSync { if (!report.complete || await _backlogRemains(liveEngine)) { return ShortcutSyncResult('partial', records: report.records); } + // A resumed app's DeriveScheduler derives and refreshes the UI; a second + // pass here would take DerivationEngine's lock and turn its job into a no-op. + if (WidgetsBinding.instance.lifecycleState == + AppLifecycleState.resumed) { + return ShortcutSyncResult('complete', records: report.records); + } return await _derive(task); } finally { _onForegroundCommitFailure = null; @@ -216,14 +222,16 @@ class IosShortcutSync { final blocker = _blockerResult(engine.bluetoothBlocker); if (blocker != null) return blocker; if (!connected) { - if (BandOwnership.foregroundIntent) { - return const ShortcutSyncResult('alreadyRunning'); - } return ShortcutSyncResult( radioConnected ? 'failed' : 'bandUnreachable', ); } + // connect already started the offload. task.update('syncing'); + await prepareHeadlessLink(engine, paired); + // The band holds one armed epoch; this may be the only connect it gets. + await rearmHeadlessAlarm(engine); + if (task.stopped) return task.expired; for (var session = 0; session < 20 && !task.stopped; session++) { final report = await engine.runSync(timeout: task.remaining); if (task.stopped) return task.expired; diff --git a/lib/sync/shortcut_sync_task.dart b/lib/sync/shortcut_sync_task.dart index 8c3b5bab0..0e06aa568 100644 --- a/lib/sync/shortcut_sync_task.dart +++ b/lib/sync/shortcut_sync_task.dart @@ -23,6 +23,7 @@ class ShortcutSyncTask { ShortcutSyncTask(this.id, this.budget, {this.onProgress}); bool get stopped => _stopped.isCompleted; + Future get whenStopped => _stopped.future; Duration get remaining { final value = budget - _clock.elapsed; return value.isNegative ? Duration.zero : value; diff --git a/test/app_state_shortcut_sync_test.dart b/test/app_state_shortcut_sync_test.dart index 35f30506a..e534f0002 100644 --- a/test/app_state_shortcut_sync_test.dart +++ b/test/app_state_shortcut_sync_test.dart @@ -172,4 +172,22 @@ void main() { expect(engine.disconnects, 0); expect(task.stopped, isTrue); }); + + test('a stopped Shortcut stops waiting on the app-owned burst', () async { + final engine = _ConnectedEngine(); + final app = AppState.forTesting(engine: engine)..initialized = true; + addTearDown(app.dispose); + final task = ShortcutSyncTask('deadline', const Duration(seconds: 5)); + final work = app.syncForShortcut(task); + while (engine.runs == 0) { + await Future.delayed(const Duration(milliseconds: 5)); + } + task.stop(); + // Returns while the burst is still running, so the gate is not held for it. + final report = await work.timeout(const Duration(seconds: 1)); + expect(report.complete, isFalse); + expect(engine.reply.isCompleted, isFalse); + expect(engine.disconnects, 0); + engine.reply.complete(SyncReport(0, 0, true)); + }); } diff --git a/test/ios_shortcut_sync_test.dart b/test/ios_shortcut_sync_test.dart index 07d73109b..db40e15be 100644 --- a/test/ios_shortcut_sync_test.dart +++ b/test/ios_shortcut_sync_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/data/db.dart'; @@ -74,6 +75,19 @@ void main() { }, ); + test('a Shortcut connect re-arms the alarm like every headless connect', () { + final src = File('lib/sync/ios_shortcut_sync.dart').readAsStringSync(); + final headless = + src.substring(src.indexOf('Future _headless(')); + final connected = headless.indexOf('if (!connected)'); + expect(headless.indexOf('await prepareHeadlessLink('), + greaterThan(connected)); + expect(headless.indexOf('await rearmHeadlessAlarm('), + greaterThan(connected)); + expect(headless.indexOf('await rearmHeadlessAlarm('), + lessThan(headless.indexOf('engine.runSync('))); + }); + test('shared headless event callback retains alarm confirmation', () async { final engine = createHeadlessSyncEngine( paired: PairedDevice('test', 'serial'), diff --git a/test/reset_quiesces_ingest_test.dart b/test/reset_quiesces_ingest_test.dart index fba6fdc55..8e01efff4 100644 --- a/test/reset_quiesces_ingest_test.dart +++ b/test/reset_quiesces_ingest_test.dart @@ -116,13 +116,16 @@ void main() { // Same isolate (see reset_gate.dart), so one static covers both. test('the headless drain refuses to start while a reset is running', () { - expect(bg.contains('if (ResetGate.active) {'), isTrue, + // Sliced to runHeadlessSync: the shared engine factory above it has its + // own ResetGate checks, which must not stand in for this one. + final run = bg.substring(bg.indexOf('Future runHeadlessSync(')); + expect(run.contains('if (ResetGate.active) {'), isTrue, reason: 'runHeadlessSync must bail before it drains'); // Ahead of PairedDevice.load(), which is the only thing standing between a // reset and a fresh drain today — and only by accident, via prefs.clear(). // The real CALL, not the mention of it in the comment above the guard. - expect(bg.indexOf('ResetGate.active'), - lessThan(bg.indexOf('final paired = await PairedDevice.load();'))); + expect(run.indexOf('ResetGate.active'), + lessThan(run.indexOf('final paired = await PairedDevice.load();'))); }); test('every headless write path is gated', () { From 78f275bb84a0e0bf7f46bc9f1ed149c88242a286 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:09:31 +0530 Subject: [PATCH 6/9] shortcut: send ready only after appstate registers its hooks so a cold launch doesn't go headless --- lib/state/app_state.dart | 3 +-- lib/sync/ios_shortcut_sync.dart | 22 +++++++++++++++++++++- test/ios_shortcut_sync_test.dart | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 4b6af6562..9ebc725a3 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1452,8 +1452,7 @@ class AppState extends ChangeNotifier { // skip the headless BLE path (it would fight FBP for the peripheral) — route // them to a catch-up pull over the existing live connection instead. IosBgTask.foregroundPull = foregroundCatchUp; - IosShortcutSync.foregroundSync = syncForShortcut; - IosShortcutSync.foregroundEngine = () => engine; + IosShortcutSync.attachForeground(syncForShortcut, () => engine); taskerBridge; // force init: register the method channel handler // A paired sensor's live beats, into the same trace as the band's. Touches // no radio — `HrsLink.reading` is a plain notifier whose identity survives diff --git a/lib/sync/ios_shortcut_sync.dart b/lib/sync/ios_shortcut_sync.dart index cdaf2e1b2..5b832b06f 100644 --- a/lib/sync/ios_shortcut_sync.dart +++ b/lib/sync/ios_shortcut_sync.dart @@ -29,6 +29,26 @@ class IosShortcutSync { static Future Function(ShortcutSyncTask)? foregroundSync; static BleEngine? Function()? foregroundEngine; + static bool _listening = false; + + /// Native holds Shortcut runs until 'ready'. Sending it only once AppState has + /// registered its hooks keeps a cold launch off the headless path, which would + /// hold the band lease while the app's own session waits on it. + static void attachForeground( + Future Function(ShortcutSyncTask) sync, + BleEngine? Function() engine, + ) { + foregroundSync = sync; + foregroundEngine = engine; + if (!_listening) return; + unawaited( + channel + .invokeMethod('ready') + .catchError( + (Object error) => debugPrint('[shortcut-sync] ready: $error'), + ), + ); + } static Future init() async { if (!Platform.isIOS) return; @@ -46,7 +66,7 @@ class IosShortcutSync { throw MissingPluginException(); } }); - await channel.invokeMethod('ready'); + _listening = true; } static Future run(String id, Duration budget) async { diff --git a/test/ios_shortcut_sync_test.dart b/test/ios_shortcut_sync_test.dart index db40e15be..c59c77d4d 100644 --- a/test/ios_shortcut_sync_test.dart +++ b/test/ios_shortcut_sync_test.dart @@ -88,6 +88,23 @@ void main() { lessThan(headless.indexOf('engine.runSync('))); }); + test('native is told ready only once AppState registered its hooks', () { + final src = File('lib/sync/ios_shortcut_sync.dart').readAsStringSync(); + final init = src.substring( + src.indexOf('static Future init()'), + src.indexOf('static Future run('), + ); + expect(init, isNot(contains("'ready'"))); + final attach = src.substring( + src.indexOf('static void attachForeground('), + src.indexOf('static Future init()'), + ); + expect(attach.indexOf("'ready'"), + greaterThan(attach.indexOf('foregroundEngine = engine'))); + final app = File('lib/state/app_state.dart').readAsStringSync(); + expect(app, contains('IosShortcutSync.attachForeground(')); + }); + test('shared headless event callback retains alarm confirmation', () async { final engine = createHeadlessSyncEngine( paired: PairedDevice('test', 'serial'), From 5ab7916ba061d8bcb070b748a331c3736da13a32 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:22:34 +0530 Subject: [PATCH 7/9] shortcut sync: report syncing once the burst starts after a wait, and let a resume foreground the app while a bg shortcut connect holds busy --- lib/state/app_state.dart | 25 ++++++++++++++----- test/app_state_shortcut_sync_test.dart | 34 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 9ebc725a3..016d6485b 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -5046,24 +5046,34 @@ class AppState extends ChangeNotifier { } Future openSession({bool foreground = true}) async { - if (busy || paired == null) return; - BandOwnership.markForegroundIntent(true); - _log('[OWNERSHIP] foreground intent on (${BandOwnership.debugState})'); + if (paired == null) return; // Returning to the foreground with the connection still alive (kept during // background): don't tear it down and reconnect — just reclaim ownership. final wasBackground = _background; + // Applied even when a session is already in flight: a background + // Shortcut's openSession(foreground: false) can hold busy while the user + // opens the app, and nothing else clears _background. Back in the + // foreground with an OS CPU/memory budget again — let the scheduler drain + // any derive jobs that queued (durably) while backgrounded. if (foreground) { _background = false; engine.setBackground(false); + _deriveScheduler.setBackground(false); } + if (busy) { + if (foreground && wasBackground) { + _nudgeLive(); + unawaited(_refreshHighFreqWakeWindow()); + } + return; + } + BandOwnership.markForegroundIntent(true); + _log('[OWNERSHIP] foreground intent on (${BandOwnership.debugState})'); // Coming back after hours (or days) suspended: re-read the phone's steps // for whatever day it is NOW. if (foreground && phoneStepsEnabled) { unawaited(syncPhoneSteps()); } - // Back in the foreground with an OS CPU/memory budget again — let the - // scheduler drain any derive jobs that queued (durably) while backgrounded. - if (foreground) _deriveScheduler.setBackground(false); if (foreground && wasBackground && engine.isConnected) { IosBleRestore.foregroundActive = true; await IosBleRestore.setOwnsBand(true); @@ -5474,6 +5484,9 @@ class AppState extends ChangeNotifier { if (background && !engine.isConnected) await _armRecovery(); } if (task.stopped || !engine.isConnected) return SyncReport(0, 0, false); + // The waits above left 'starting'/'waiting', which a deadline reads as + // timedOut even though the burst is banking records. + task.update('syncing'); final burst = _kickSyncBurst(kickFirst: _syncBurst == null).then((report) { if (report.records > 0) _deriveScheduler.markStoredData(); if (!_disposed) notifyListeners(); diff --git a/test/app_state_shortcut_sync_test.dart b/test/app_state_shortcut_sync_test.dart index e534f0002..7137ef63b 100644 --- a/test/app_state_shortcut_sync_test.dart +++ b/test/app_state_shortcut_sync_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ble/ble_engine.dart'; import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/sync/paired_device.dart'; import 'package:openstrap_edge/state/app_state.dart'; import 'package:openstrap_edge/sync/reset_gate.dart'; import 'package:openstrap_edge/sync/shortcut_sync_task.dart'; @@ -17,6 +18,7 @@ class _ConnectedEngine extends BleEngine { int disconnects = 0; int probes = 0; Duration quietFor = Duration.zero; + bool? background; _ConnectedEngine() : super(onRecord: (_, _) async {}, onState: (_) {}); @@ -47,6 +49,9 @@ class _ConnectedEngine extends BleEngine { @override Future disconnect() async => disconnects++; + + @override + void setBackground(bool value) => background = value; } void main() { @@ -190,4 +195,33 @@ void main() { expect(engine.disconnects, 0); engine.reply.complete(SyncReport(0, 0, true)); }); + + test('a deadline after waiting on a busy app reports partial', () async { + final engine = _ConnectedEngine(); + final app = AppState.forTesting(engine: engine) + ..initialized = true + ..busy = true; + addTearDown(app.dispose); + final task = ShortcutSyncTask('waited', const Duration(seconds: 5)); + final work = app.syncForShortcut(task); + expect(task.phase, 'waiting'); + app.busy = false; + while (engine.runs == 0) { + await Future.delayed(const Duration(milliseconds: 5)); + } + expect(task.expired.status, 'partial'); + task.stop(); + await work; + engine.reply.complete(SyncReport(0, 0, true)); + }); + + test('opening the app during an in-flight session foregrounds it', () async { + final engine = _ConnectedEngine(); + final app = AppState.forTesting(engine: engine) + ..paired = PairedDevice('r1', 's1', generation: 'gen4') + ..busy = true; + addTearDown(app.dispose); + await app.openSession(); + expect(engine.background, isFalse); + }); } From 73bb6bf6a551ee3ffc74ed0ebc9457de70360329 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:37:45 +0530 Subject: [PATCH 8/9] skip derive enqueue when a shortcut burst finishes after dispose --- lib/state/app_state.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 016d6485b..7eec57127 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -5488,8 +5488,9 @@ class AppState extends ChangeNotifier { // timedOut even though the burst is banking records. task.update('syncing'); final burst = _kickSyncBurst(kickFirst: _syncBurst == null).then((report) { + if (_disposed) return report; if (report.records > 0) _deriveScheduler.markStoredData(); - if (!_disposed) notifyListeners(); + notifyListeners(); return report; }); // The burst is the app's and can run for many minutes; a stopped Shortcut From 64411113b55b0fee85ff36b24c6ae8fa2a5ab8f2 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:52:08 +0530 Subject: [PATCH 9/9] sync burst returns totals across sessions so a dropped last session doesn't zero the shortcut's record count --- lib/state/app_state.dart | 11 ++++++--- test/app_state_shortcut_sync_test.dart | 31 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 7eec57127..458954862 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -4252,7 +4252,8 @@ class AppState extends ChangeNotifier { // real progress, so this only runs long when there's genuinely a lot to pull. int maxSessions = 20, }) async { - var last = SyncReport(0, 0, false); + // Totals for the whole burst; `complete` is the final session's. + var total = SyncReport(0, 0, false); for (var i = 0; i < maxSessions && engine.isConnected; i++) { // Terminal `Stuck`: a burst failed validation // 15 times and the abort went out, so this connection's history is over. @@ -4301,7 +4302,11 @@ class AppState extends ChangeNotifier { strapNewest != null && frontierAfter != null && (strapNewest - frontierAfter) > 300; - last = report; + total = SyncReport( + total.records + report.records, + total.batches + report.batches, + report.complete, + ); await LocalDb.upsertSyncLedgerEntry( status: report.complete ? 'complete' : 'session_end', metaPatch: { @@ -4352,7 +4357,7 @@ class AppState extends ChangeNotifier { 'frontier still behind strap newest ($strapNewest > $frontierAfter).', ); } - return last; + return total; } // ── pairing (LOCAL only) ──────────────────────────────────────────────────── diff --git a/test/app_state_shortcut_sync_test.dart b/test/app_state_shortcut_sync_test.dart index 7137ef63b..f83b419da 100644 --- a/test/app_state_shortcut_sync_test.dart +++ b/test/app_state_shortcut_sync_test.dart @@ -54,6 +54,23 @@ class _ConnectedEngine extends BleEngine { void setBackground(bool value) => background = value; } +/// Session 1 banks records and advances the frontier with backlog left on the +/// strap; session 2 gets nothing because the link dropped. +class _TwoSessionEngine extends _ConnectedEngine { + @override + int? get strapHistoryNewestTs => 1000000; + + @override + Future runSync({ + Duration timeout = const Duration(seconds: 600), + }) async { + runs++; + if (runs > 1) return SyncReport(0, 0, false); + await LocalDb.setCursor('rec_ts_hw', '500'); + return SyncReport(42, 3, false); + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -215,6 +232,20 @@ void main() { engine.reply.complete(SyncReport(0, 0, true)); }); + test('a burst reports records banked by every session, not the last', () async { + await LocalDb.deleteCursor('rec_ts_hw'); + final engine = _TwoSessionEngine(); + final app = AppState.forTesting(engine: engine)..initialized = true; + addTearDown(app.dispose); + final report = await app.syncForShortcut( + ShortcutSyncTask('two', const Duration(seconds: 5)), + ); + expect(engine.runs, 2); + expect(report.records, 42); + expect(report.batches, 3); + expect(report.complete, isFalse); + }); + test('opening the app during an in-flight session foregrounds it', () async { final engine = _ConnectedEngine(); final app = AppState.forTesting(engine: engine)