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..2acca3a0a
--- /dev/null
+++ b/guides/IOS_SHORTCUTS.md
@@ -0,0 +1,36 @@
+# Syncing with iOS Shortcuts
+
+## Sync Data
+
+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.
+
+**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, 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.
+
+The action has about 25 seconds, including app startup. A large backlog can need more than one run, or open Edge to catch up.
+
+### Results
+
+- **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.
+
+## Open Edge and Sync
+
+Brings the app forward and runs the same sync. Use it interactively, not from locked-phone automations.
+
+## Native tests
+
+```sh
+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
+```
+
+The `ShortcutIntents` scheme runs the system-invocation tests. It needs Xcode 27, an iOS 27 simulator, and a development-signed build.
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 3e092266b..a27126a15 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 5608383a5..310cdbf25 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.
// Main queue, not nil: nil runs the handler on a background queue, and the
@@ -49,6 +57,7 @@ import CoreMotion
// paired watch. See WatchBridge.swift.
WatchBridge.shared.activate()
+ _ = sharedEngine
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
@@ -65,56 +74,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/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) {
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 d76efe642..125d48f2c 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -14,6 +14,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 'telemetry/firebase_bridge.dart';
@@ -113,6 +114,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 091d0fece..f7da0d75b 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
@@ -1383,22 +1385,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 {
@@ -1465,6 +1472,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.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
@@ -1577,6 +1585,10 @@ class AppState extends ChangeNotifier {
@override
void dispose() {
+ if (IosShortcutSync.foregroundSync == syncForShortcut) {
+ IosShortcutSync.foregroundSync = null;
+ IosShortcutSync.foregroundEngine = null;
+ }
_syncQuietTimer?.cancel();
_syncQuietTimer = null;
_disposed = true;
@@ -4282,7 +4294,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.
@@ -4331,7 +4344,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: {
@@ -4382,7 +4399,7 @@ class AppState extends ChangeNotifier {
'frontier still behind strap newest ($strapNewest > $frontierAfter).',
);
}
- return last;
+ return total;
}
// ── pairing (LOCAL only) ────────────────────────────────────────────────────
@@ -5122,24 +5139,36 @@ class AppState extends ChangeNotifier {
}
}
- Future openSession() async {
- if (busy || paired == null) return;
- BandOwnership.markForegroundIntent(true);
- _log('[OWNERSHIP] foreground intent on (${BandOwnership.debugState})');
+ Future openSession({bool foreground = true}) async {
+ 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;
- _background = false;
- engine.setBackground(false);
+ // 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 (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 && wasBackground && engine.isConnected) {
IosBleRestore.foregroundActive = true;
await IosBleRestore.setOwnsBand(true);
EdgeTracking.start(); // Android: keep the foreground service up (idempotent)
@@ -5529,6 +5558,51 @@ 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.
+ 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);
+ // 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 (_disposed) return report;
+ if (report.records > 0) _deriveScheduler.markStoredData();
+ 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
/// 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 27d71de95..7324bc073 100644
--- a/lib/sync/background_sync.dart
+++ b/lib/sync/background_sync.dart
@@ -104,7 +104,7 @@ Future headlessArmAndConfirm(Future Function() arm,
}
/// 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');
@@ -115,6 +115,177 @@ 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;
+}
+
+/// 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();
+ await headlessArmAndConfirm(
+ () => armNextScheduledOccurrence(
+ engine: engine,
+ schedule: schedule,
+ currentArmedEpoch: prefs.getInt('alarm_epoch'),
+ ),
+ LocalDb.alarmSetConfirmedSince,
+ );
+ } 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
@@ -150,91 +321,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.
@@ -247,76 +334,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();
- await headlessArmAndConfirm(
- () => armNextScheduledOccurrence(
- engine: engine,
- schedule: schedule,
- currentArmedEpoch: prefs.getInt('alarm_epoch'),
- ),
- LocalDb.alarmSetConfirmedSince,
- );
- } catch (e) {
- debugPrint('[bgsync] alarm re-arm skipped: $e');
- }
+ await rearmHeadlessAlarm(engine);
} finally {
await engine.disconnect();
}
@@ -328,7 +354,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..5b832b06f
--- /dev/null
+++ b/lib/sync/ios_shortcut_sync.dart
@@ -0,0 +1,307 @@
+import 'dart:async';
+import 'dart:io';
+
+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';
+
+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 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;
+ 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();
+ }
+ });
+ _listening = true;
+ }
+
+ 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);
+ }
+ // 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;
+ 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) {
+ 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;
+ final backlogRemains = await _backlogRemains(engine);
+ if (report.complete && !backlogRemains) {
+ await engine.disconnect();
+ task.onStop = null;
+ return await _derive(task);
+ }
+ if (!report.complete ||
+ report.batches == 0 ||
+ engine.historyStuckThisSession ||
+ !engine.isConnected) {
+ break;
+ }
+ // 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;
+ if (ResetGate.active) throw StateError('data reset in progress');
+ 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..0e06aa568
--- /dev/null
+++ b/lib/sync/shortcut_sync_task.dart
@@ -0,0 +1,73 @@
+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;
+ Future get whenStopped => _stopped.future;
+ 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'
+ // the transfer is already committed once derive starts
+ : phase == 'processing'
+ ? 'complete'
+ : phase == 'syncing'
+ ? '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..f83b419da
--- /dev/null
+++ b/test/app_state_shortcut_sync_test.dart
@@ -0,0 +1,258 @@
+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';
+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;
+ bool? background;
+
+ _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++;
+
+ @override
+ 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();
+
+ 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);
+ });
+
+ 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));
+ });
+
+ 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('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)
+ ..paired = PairedDevice('r1', 's1', generation: 'gen4')
+ ..busy = true;
+ addTearDown(app.dispose);
+ await app.openSession();
+ expect(engine.background, isFalse);
+ });
+}
diff --git a/test/ios_shortcut_sync_test.dart b/test/ios_shortcut_sync_test.dart
new file mode 100644
index 000000000..c59c77d4d
--- /dev/null
+++ b/test/ios_shortcut_sync_test.dart
@@ -0,0 +1,173 @@
+import 'dart:async';
+import 'dart:io';
+
+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('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('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'),
+ );
+ 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..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', () {
@@ -132,7 +135,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..be5ab1d11
--- /dev/null
+++ b/test/shortcut_sync_task_test.dart
@@ -0,0 +1,97 @@
+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',
+ 'processing' => 'complete',
+ _ => 'partial',
+ });
+ expect(result.records, 9);
+ }
+ },
+ );
+
+ test(
+ 'cancellation stops once and does not report subsequent progress',
+ () async {
+ final updates =