From b53cdc2c2fcfb7a4e094b3b6eb678a39f3c5fe69 Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 12 Sep 2026 18:04:28 +0200 Subject: [PATCH 1/2] feat(ios): add background band sync Shortcuts Route Sync Data through a shared headless-capable Flutter engine and the existing band ownership and commit-before-ACK persistence path. Add opt-in connectivity-error suppression, an interactive foreground fallback, and Dart/native/system-invocation regression tests. --- README.md | 3 +- guides/IOS_SHORTCUTS.md | 61 ++++ ios/OpenStrapIntents.swift | 61 +++- ios/Runner.xcodeproj/project.pbxproj | 172 ++++++++++- .../xcschemes/ShortcutIntents.xcscheme | 128 ++++++++ ios/Runner/AppDelegate.swift | 42 ++- ios/Runner/Info.plist | 4 - ios/Runner/SceneDelegate.swift | 12 + ios/Runner/ShortcutSyncBridge.swift | 160 ++++++++++ ios/RunnerTests/RunnerTests.swift | 161 +++++++++- ios/ShortcutUITests/Info.plist | 8 + ios/ShortcutUITests/ShortcutUITests.swift | 80 +++++ lib/main.dart | 3 + lib/state/app_state.dart | 68 ++++- lib/sync/background_sync.dart | 77 ++--- lib/sync/ios_shortcut_sync.dart | 275 ++++++++++++++++++ lib/sync/shortcut_sync_task.dart | 69 +++++ test/app_state_shortcut_sync_test.dart | 130 +++++++++ test/ios_shortcut_sync_test.dart | 88 ++++++ test/shortcut_sync_task_test.dart | 96 ++++++ 20 files changed, 1614 insertions(+), 84 deletions(-) create mode 100644 guides/IOS_SHORTCUTS.md create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/ShortcutIntents.xcscheme create mode 100644 ios/Runner/ShortcutSyncBridge.swift create mode 100644 ios/ShortcutUITests/Info.plist create mode 100644 ios/ShortcutUITests/ShortcutUITests.swift create mode 100644 lib/sync/ios_shortcut_sync.dart create mode 100644 lib/sync/shortcut_sync_task.dart create mode 100644 test/app_state_shortcut_sync_test.dart create mode 100644 test/ios_shortcut_sync_test.dart create mode 100644 test/shortcut_sync_task_test.dart diff --git a/README.md b/README.md index d4ee5666..4d8e11cc 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 00000000..8f152af8 --- /dev/null +++ b/guides/IOS_SHORTCUTS.md @@ -0,0 +1,61 @@ +# Syncing with iOS Shortcuts + +## Sync Data + +On iOS 16 and later, add **Edge → Sync Data** to a shortcut. Pair your band in Edge and grant Bluetooth access before using an unattended automation. The action runs without opening Edge's interface. It starts a real band sync, rather than scheduling a discretionary background refresh or simulating a refresh gesture. + +Expand the action to enable **Ignore Connectivity Errors**. It is off by default. When enabled, Bluetooth being unavailable or the paired band being unreachable returns a successful text result beginning with `Skipped:` instead of throwing an action error. Missing pairing, denied Bluetooth permission, startup failures, and other failures are not suppressed. The action does not display a dialog or post a notification of its own. This option cannot disable notifications or progress UI that iOS or Shortcuts independently chooses to display. + +For a personal automation, choose a Time of Day trigger, select **Run Immediately** where offered, and run the shortcut. Configure the automation's own notification options separately. Several daily triggers can provide several sync opportunities; they do not guarantee that the band is reachable or that iOS will finish every invocation. + +### Results + +- **Band data synchronized:** the band transfer completed and Edge requested light processing and refreshed its widget snapshot. An existing derivation takes precedence. This is not a claim that every historical day has received full heavy analysis or that HealthKit export completed. +- **Sync is incomplete:** the invocation ran out of time or the transfer ended early. Previously saved data is retained. Run the action again or open Edge for a longer catch-up. +- **A sync request is already active:** another request owns the sync path. No competing transfer was started. +- **Skipped:** one of the two opted-out connectivity failures occurred; no successful sync is claimed. + +The ordinary action has a 25-second native deadline, including Flutter startup. The Dart transfer receives a slightly shorter budget so it can report partial progress before that deadline. An iOS interruption can still prevent a result from being returned. Apple's ordinary App Intent execution budget is approximately 30 seconds; see [LongRunningIntent](https://developer.apple.com/documentation/appintents/longrunningintent). + +## Lifecycle and data safety + +For an interactive fallback, **Open Edge and Sync** brings the app forward and invokes the same sync bridge. It is not intended for unattended locked-device automations. The action itself remains bounded, but an app-owned session can keep catching up while Edge is open. + +The application retains one headless-capable Flutter engine. Foreground scenes attach to that same engine, keeping the UI, background wakes, and Shortcuts in one isolate with the same band-ownership guards. The native bridge waits for an explicit Dart readiness handshake, correlates replies with requests, and ignores late replies after cancellation. + +An existing app-owned connection and sync burst are reused. Otherwise the action takes the existing headless gate and band lease and uses the same `BleEngine` and `BandHost` persistence callbacks as normal background sync. Records and the durable cursor are committed before a batch is acknowledged to the band. + +Cancellation or a deadline stops an action-owned connection. Its gate and lease remain held until serialized BLE cleanup finishes. Cancelling a Shortcut does not disconnect an independent app-owned live session; that session can continue its normal synchronization. A Shortcut never pretends to finish merely because it started asynchronous work. + +## Validation + +The Dart task tests cover completion, deadline classification, cancellation, progress suppression after cancellation, and ownership retention during cleanup. The iOS Runner tests cover bridge readiness, concurrent requests, late replies, cancellation, malformed responses, and the exact connectivity-error allowlist. A clean-simulator integration test calls through the real Flutter bridge and expects a missing-pairing error rather than success. + +The separate **ShortcutIntents** Xcode scheme uses Apple's `AppIntentsTesting` framework on iOS 27 to exercise background invocation, relaunch after termination, and the foreground fallback through the system's intent infrastructure. It does not replace the ordinary **Runner** scheme or raise the application's deployment target. Use a clean simulator and a separate bundle identifier so these tests cannot use a real pairing or change an existing installation: + +```sh +flutter pub get +flutter build ios --simulator --debug +cd ios +xcodebuild -workspace Runner.xcworkspace -scheme Runner \ + -destination "id=$SIMULATOR_ID" -only-testing:RunnerTests \ + BUILD_DIR="$PWD/../build/ios" CODE_SIGNING_ALLOWED=NO \ + APP_BUNDLE_IDENTIFIER=com.example.openstrapEdge.shortcuttests \ + APP_GROUP_IDENTIFIER=group.com.example.openstrap.shortcuttests test +``` + +For the system-invocation tests, use Xcode 27 with an iOS 27 simulator and replace `-scheme Runner -only-testing:RunnerTests` with `-scheme ShortcutIntents`. Unlike the native Runner tests, `AppIntentsTesting` requires the app and UI test runner to be development-signed by the same team. Replace `CODE_SIGNING_ALLOWED=NO` with `CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY="Apple Development" DEVELOPMENT_TEAM="$TEAM_ID" APPLE_DEVELOPMENT_TEAM="$TEAM_ID"`, where `TEAM_ID` is your Apple development team. See [Apple's AppIntentsTesting walkthrough](https://developer.apple.com/videos/play/wwdc2026/295/). + +Simulator tests do not establish real Bluetooth transfer reliability. Before relying on an unattended automation, test on an iPhone with a paired band: + +| Scenario | Expected behavior | +| --- | --- | +| Edge open, then suspended | The same connection is reused; no duplicate drain. | +| Edge terminated, then action invoked | Flutter starts without requiring a visible scene and the action reaches the sync service. | +| Phone locked | The action can attempt to run, subject to storage availability and iOS policy. | +| Bluetooth off or band out of range | Error with the option off; successful `Skipped:` result with it on. | +| Bluetooth permission denied | Error with either option value. | +| Large backlog or cancellation | No false completion or ACK of unsaved data; a later run can resume. | +| UI refresh overlaps the action | Only one transfer owns the band. | + +Test force-quit followed by a scheduled personal automation separately from Bluetooth state restoration. These are different launch mechanisms; neither a foreground test nor a Simulator test proves the force-quit/locked-device scheduling case. diff --git a/ios/OpenStrapIntents.swift b/ios/OpenStrapIntents.swift index f742cfc7..a7a5cb4e 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" @@ -140,6 +179,12 @@ struct StartBreathingIntent: 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 10d12081..838823c6 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 00000000..0de1269b --- /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 d92e2bd5..293ff710 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -6,7 +6,15 @@ import BackgroundTasks import CoreMotion @main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { +@objc class AppDelegate: FlutterAppDelegate { + // One isolate keeps the UI, background wakes, and Shortcuts under the same BLE ownership gate. + lazy var sharedEngine: FlutterEngine = { + let engine = FlutterEngine(name: "openstrap", project: nil, allowHeadlessExecution: true) + engine.run() + registerEngine(engine) + return engine + }() + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -16,7 +24,7 @@ import CoreMotion BleRestoreManager.shared.start(launchOptions: launchOptions) // BGTaskScheduler registration MUST happen before didFinishLaunching returns. - // The channel wiring (messenger) happens in didInitializeImplicitFlutterEngine below; + // The channel wiring (messenger) happens in registerEngine below; // here we only register the identifier with the OS so it survives to that point. // schedule() is called after the channel is wired so Dart is ready to handle the task. BGTaskScheduler.shared.register( @@ -47,6 +55,7 @@ import CoreMotion // paired watch. See WatchBridge.swift. WatchBridge.shared.activate() + _ = sharedEngine return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @@ -63,56 +72,59 @@ import CoreMotion WatchBridge.shared.pushCurrentState() } - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + private func registerEngine(_ registry: FlutterPluginRegistry) { + GeneratedPluginRegistrant.register(with: registry) // Live Activity MethodChannel (start/update/end the workout activity). // LiveActivityBridge lives in LiveActivityBridge.swift (Runner target). - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "LiveActivityBridge") { + if let registrar = registry.registrar(forPlugin: "LiveActivityBridge") { LiveActivityBridge.register(messenger: registrar.messenger()) } // Breathing-session Live Activity — separate channel/attributes type from // the workout one (BreathingLiveActivityBridge.swift, Runner target). - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BreathingLiveActivityBridge") { + if let registrar = registry.registrar(forPlugin: "BreathingLiveActivityBridge") { BreathingLiveActivityBridge.register(messenger: registrar.messenger()) } // BLE-restore channel: native wake (band reconnected) → Dart headless sync. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BleRestoreManager") { + if let registrar = registry.registrar(forPlugin: "BleRestoreManager") { BleRestoreManager.shared.attach(messenger: registrar.messenger()) } // Band-gesture actions channel (double-tap → play/pause, skip, ring phone). - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "ActionBridge") { + if let registrar = registry.registrar(forPlugin: "ActionBridge") { ActionBridge.register(messenger: registrar.messenger()) } // AccessorySetupKit pairing bridge (iOS 18+). The ASK picker provisions the WHOOP so // iOS 26 keeps the app eligible for background relaunch (TN3115). No-op pre-iOS 18. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "AccessorySetup") { + if let registrar = registry.registrar(forPlugin: "AccessorySetup") { AccessorySetup.register(messenger: registrar.messenger()) } // Home-screen icon switching (setAlternateIconName). iOS only — see the // bridge below for the system-alert cost it carries. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "AppIconBridge") { + if let registrar = registry.registrar(forPlugin: "AppIconBridge") { AppIconBridge.register(messenger: registrar.messenger()) } // Build-time iOS configuration exposed to Dart without requiring --dart-define. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "ConfigBridge") { + if let registrar = registry.registrar(forPlugin: "ConfigBridge") { ConfigBridge.register(messenger: registrar.messenger()) } // The phone's OWN step count (CMPedometer), not HealthKit's multi-writer // aggregate. See lib/health/phone_pedometer.dart. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "PedometerBridge") { + if let registrar = registry.registrar(forPlugin: "PedometerBridge") { PedometerBridge.register(messenger: registrar.messenger()) } // HKWorkoutRoute → Dart. Coordinates only; the `health` plugin still reads // the workouts themselves. See lib/health/health_workout_import.dart. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "HealthRouteBridge") { + if let registrar = registry.registrar(forPlugin: "HealthRouteBridge") { HealthRouteBridge.register(messenger: registrar.messenger()) } // HealthKit sleep replace (inBed + Core/Deep/REM). See HealthKitSleepWriter.swift. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "HealthKitSleepWriter") { + if let registrar = registry.registrar(forPlugin: "HealthKitSleepWriter") { HealthKitSleepWriter.register(messenger: registrar.messenger()) } + if let registrar = registry.registrar(forPlugin: "ShortcutSyncBridge") { + ShortcutSyncBridge.shared.attach(messenger: registrar.messenger()) + } // BGTask channel: Dart handler for opportunistic headless sync + heavy derivation. - if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BackgroundTaskManager") { + if let registrar = registry.registrar(forPlugin: "BackgroundTaskManager") { BackgroundTaskManager.wireChannel(messenger: registrar.messenger()) // Now that the channel is wired, submit the first task requests // (heavy processing + light sync-only refresh). diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index e04f934c..aa08690a 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 a6a09329..0dd56b61 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 00000000..bf58dcfd --- /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 86a7c3b1..2d2de79b 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 00000000..56e6a7f4 --- /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 00000000..25cd15fc --- /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 421ba5b5..3f9154e7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ import 'state/locale_controller.dart'; import 'state/units_controller.dart'; import 'sync/headless_boot.dart'; import 'sync/ios_bg_task.dart'; +import 'sync/ios_shortcut_sync.dart'; import 'theme/theme_controller.dart'; import 'widget/widget_service.dart'; import 'package:firebase_core/firebase_core.dart'; @@ -115,6 +116,8 @@ Future main() async { .timeout(_kStartupInitTimeout); } catch (_) {/* older plugin / unsupported platform — ignore */} + await _safeInit('IosShortcutSync', IosShortcutSync.init); + // Optional startup services. A failure in any one of these must NEVER block the // first frame — they are awaited before runApp, so an unguarded throw (e.g. the // flutter_local_notifications `invalid_icon` crash) leaves the app stuck on the diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 894003a6..f3dcb332 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -97,6 +97,8 @@ import '../sync/edge_tracking.dart'; import '../sync/band_ownership.dart'; import '../sync/high_freq_wake_window.dart'; import '../sync/ios_bg_task.dart'; +import '../sync/ios_shortcut_sync.dart'; +import '../sync/shortcut_sync_task.dart'; import '../sync/paired_device.dart'; import '../sync/sync_policy.dart' show @@ -1227,9 +1229,16 @@ class AppState extends ChangeNotifier { // durable commit, same arguments, one extra await frame, and the SAME // failure contract: `commitNativeBatch` rethrows so // `DrainController.commit` still reads durability from a throw. - onCommitBatch: (raws, samples, trimTokenHex, {archives, deviceFamily}) => - _bandHost.commitNativeBatch(raws, samples, trimTokenHex, - archives: archives, deviceFamily: deviceFamily), + onCommitBatch: (raws, samples, trimTokenHex, {archives, deviceFamily}) async { + try { + await _bandHost.commitNativeBatch(raws, samples, trimTokenHex, + archives: archives, deviceFamily: deviceFamily); + } catch (_) { + // A persistence failure must not look like a quiet partial Shortcut sync. + IosShortcutSync.foregroundCommitFailed(); + rethrow; + } + }, // Pre-setup fallback only: the drain path archives inside commitSyncBatch. onArchiveRecord: LocalDb.archiveRawRecord, cursorReader: (base) => @@ -1283,6 +1292,8 @@ class AppState extends ChangeNotifier { // skip the headless BLE path (it would fight FBP for the peripheral) — route // them to a catch-up pull over the existing live connection instead. IosBgTask.foregroundPull = foregroundCatchUp; + IosShortcutSync.foregroundSync = syncForShortcut; + IosShortcutSync.foregroundEngine = () => engine; taskerBridge; // force init: register the method channel handler // A paired sensor's live beats, into the same trace as the band's. Touches // no radio — `HrsLink.reading` is a plain notifier whose identity survives @@ -1360,6 +1371,10 @@ class AppState extends ChangeNotifier { @override void dispose() { + if (IosShortcutSync.foregroundSync == syncForShortcut) { + IosShortcutSync.foregroundSync = null; + IosShortcutSync.foregroundEngine = null; + } _syncQuietTimer?.cancel(); _syncQuietTimer = null; _disposed = true; @@ -4673,29 +4688,31 @@ class AppState extends ChangeNotifier { } // ── session: drain history, go live, stay connected ────────────────────────── - Future openSession() async { + Future openSession({bool foreground = true}) async { if (busy || paired == null) return; BandOwnership.markForegroundIntent(true); _log('[OWNERSHIP] foreground intent on (${BandOwnership.debugState})'); // Returning to the foreground with the connection still alive (kept during // background): don't tear it down and reconnect — just reclaim ownership. final wasBackground = _background; - _background = false; - engine.setBackground(false); + if (foreground) { + _background = false; + engine.setBackground(false); + } // Coming back after hours (or days) suspended: re-read the phone's steps // for whatever day it is NOW. - if (phoneStepsEnabled) { + if (foreground && phoneStepsEnabled) { unawaited(syncPhoneSteps()); } // Back in the foreground with an OS CPU/memory budget again — let the // scheduler drain any derive jobs that queued (durably) while backgrounded. - _deriveScheduler.setBackground(false); + if (foreground) _deriveScheduler.setBackground(false); // A background live downgrade may still be writing (its flags clear only on // completion). Let it finish before any reclaim path below re-arms live, so // the re-arm sees settled flags and its ON writes can't interleave with the // disable's trailing OFF writes. await _settleBgLiveDowngrade(); - 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) @@ -4809,7 +4826,11 @@ class AppState extends ChangeNotifier { // (awaited, I/O-bound) recovery were then wiped by _resetLivePedometer. await _recoverOrphanedLiveSession(); _resetLivePedometer(); // fresh live step count for this connected session - await engine.enableLiveStreams(); + if (_background && !_hasLiveConsumer) { + if (Platform.isIOS) await engine.enableHrOnlyLive(); + } else { + await engine.enableLiveStreams(); + } unawaited( _kickSyncBurst(kickFirst: false).then((report) async { _log( @@ -5095,6 +5116,33 @@ class AppState extends ChangeNotifier { Future syncNow() => openSession(); + Future syncForShortcut(ShortcutSyncTask task) async { + 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 (engine.isConnected && + isLinkStale(engine.sinceLastRx, liveStreamArmed: engine.liveEnabled)) { + await engine.disconnect(); + } + if (!engine.isConnected) { + task.update('connecting'); + // A background Shortcut must not enable the UI's high-rate live streams. + await openSession(foreground: !_background); + } + if (task.stopped || !engine.isConnected) return SyncReport(0, 0, false); + final report = await _kickSyncBurst(kickFirst: _syncBurst == null); + if (report.records > 0) _deriveScheduler.markStoredData(); + if (!_disposed) notifyListeners(); + return report; + } + Future _refreshHighFreqWakeWindow() async { if (!engine.isConnected) return; try { diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index fcea0ce0..31eaddd5 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -59,7 +59,7 @@ import 'paired_device.dart'; import 'sync_policy.dart'; /// 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'); @@ -70,6 +70,42 @@ Future _loadProfile() async { } } +/// Every headless caller must use the same commit-before-ACK persistence path. +BleEngine createHeadlessSyncEngine({ + void Function(int records)? onCommitted, + void Function(Object error)? onCommitError, +}) { + late final BandHost bandHost; + final engine = BleEngine( + onRecord: (sample, raw) => LocalDb.insertRecord(raw, sample), + onState: (_) {}, + onEvent: (id, ts, hex) => + LocalDb.insertEvent(id, ts, hex, deviceId: LocalDb.kPrimaryDeviceId), + log: (l) => debugPrint('[bgsync] $l'), + onRecordsBatch: LocalDb.insertRecordsBatch, + onCommitBatch: (raws, samples, trimTokenHex, {archives, deviceFamily}) async { + try { + await bandHost.commitNativeBatch(raws, samples, trimTokenHex, + archives: archives, deviceFamily: deviceFamily); + } catch (e) { + onCommitError?.call(e); + rethrow; + } + onCommitted?.call(samples.length); + }, + onArchiveRecord: LocalDb.archiveRawRecord, + cursorReader: (base) => + LocalDb.getCursorInt(LocalDb.cursorKeyFor(base, LocalDb.kPrimaryDeviceId)), + isBackgroundDrainer: true, + ); + bandHost = BandHost( + adapter: WhoopFramedAdapter(engine, kWhoopGen4), + deviceId: LocalDb.kPrimaryDeviceId, + onLog: (msg) => debugPrint('[bgsync][COMMIT] $msg'), + ); + return engine; +} + /// One headless LOCAL drain pass. Safe to call from a background isolate. Never /// throws. Connects-by-id if reachable, drains whatever the band buffered to /// flash into local storage (non-destructive cursor — catches up everything since @@ -95,42 +131,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( - onRecord: (sample, raw) => 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) => - LocalDb.insertEvent(id, ts, hex, deviceId: LocalDb.kPrimaryDeviceId), - log: (l) => debugPrint('[bgsync] $l'), - onRecordsBatch: LocalDb.insertRecordsBatch, - // 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, deviceFamily}) => - bandHost.commitNativeBatch(raws, samples, trimTokenHex, - archives: archives, deviceFamily: deviceFamily), - onArchiveRecord: LocalDb.archiveRawRecord, - 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(); // connect() subscribes → SET_CLOCK → INIT, so the historical offload is already // streaming when this returns. We then await it reaching HISTORY_COMPLETE. @@ -222,7 +223,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 00000000..a1828f06 --- /dev/null +++ b/lib/sync/ios_shortcut_sync.dart @@ -0,0 +1,275 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../ble/ble_engine.dart'; +import '../ble/ble_state.dart'; +import '../compute/derivation_engine.dart'; +import '../data/db.dart'; +import '../data/local_repository_impl.dart'; +import '../state/prefs.dart'; +import '../widget/widget_service.dart'; +import 'background_sync.dart'; +import 'band_ownership.dart'; +import 'headless_gate.dart'; +import 'paired_device.dart'; +import 'shortcut_sync_task.dart'; + +class IosShortcutSync { + static const channel = MethodChannel('openstrap/shortcut_sync'); + static ShortcutSyncTask? _active; + static void Function()? _onForegroundCommitFailure; + + static void foregroundCommitFailed() => _onForegroundCommitFailure?.call(); + + static Future Function(ShortcutSyncTask)? foregroundSync; + static BleEngine? Function()? foregroundEngine; + + static Future init() async { + if (!Platform.isIOS) return; + channel.setMethodCallHandler((call) async { + final args = (call.arguments as Map?) ?? const {}; + switch (call.method) { + case 'run': + final id = args['id'] as String; + final milliseconds = (args['budgetMs'] as int).clamp(1, 600000); + return (await run(id, Duration(milliseconds: milliseconds))).toMap(); + case 'cancel': + if (_active?.id == args['id']) _active?.stop('cancelled'); + return null; + default: + throw MissingPluginException(); + } + }); + await channel.invokeMethod('ready'); + } + + static Future run(String id, Duration budget) async { + if (_active != null) return const ShortcutSyncResult('alreadyRunning'); + final task = ShortcutSyncTask( + id, + budget, + onProgress: (progress) { + unawaited( + channel + .invokeMethod('progress', progress) + .catchError( + (Object error) => + debugPrint('[shortcut-sync] progress: $error'), + ), + ); + }, + ); + _active = task; + final work = () async { + try { + return await HeadlessSyncGate.tryRun('shortcut', () => _sync(task)) ?? + const ShortcutSyncResult('alreadyRunning'); + } catch (e, st) { + debugPrint('[shortcut-sync] failed: $e\n$st'); + return ShortcutSyncResult('failed', records: task.records); + } finally { + task.onStop = null; + if (identical(_active, task)) _active = null; + } + }(); + return task.waitFor(work); + } + + static ShortcutSyncResult? _blockerResult(BleBlocker? blocker) { + if (blocker == null) return null; + return ShortcutSyncResult( + blocker == BleBlocker.permissionDenied + ? 'permissionDenied' + : 'bluetoothUnavailable', + ); + } + + static Future _sync(ShortcutSyncTask task) async { + 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; + + final liveSync = foregroundSync; + final liveEngine = foregroundEngine?.call(); + if (liveSync != null && liveEngine != null) { + _onForegroundCommitFailure = () => task.stop('failed'); + task.update(liveEngine.isConnected ? 'syncing' : 'connecting'); + var radioConnected = liveEngine.isConnected; + final radio = BluetoothDevice.fromId(paired.remoteId).connectionState + .listen((state) { + if (state == BluetoothConnectionState.connected) { + radioConnected = true; + if (task.phase == 'connecting') task.update('initializing'); + } + }); + var lastBatches = liveEngine.offloadSnapshot['batches_acked'] as int; + final progress = Timer.periodic(const Duration(seconds: 1), (_) { + final batches = liveEngine.offloadSnapshot['batches_acked'] as int; + if (liveEngine.isConnected && + (task.phase == 'connecting' || task.phase == 'initializing')) { + task.update('syncing'); + } + if (batches != lastBatches) { + final delta = batches >= lastBatches + ? batches - lastBatches + : batches; + lastBatches = batches; + task.update(task.phase, batches: task.batches + delta); + } + }); + task.onStop = progress.cancel; + try { + // A cancelled Shortcut must not tear down the app's own live session. + final report = await liveSync(task); + if (task.stopped) return task.expired; + final blocker = _blockerResult(liveEngine.bluetoothBlocker); + if (blocker != null) return blocker; + if (!liveEngine.isConnected && report.records == 0) { + return ShortcutSyncResult( + radioConnected ? 'failed' : 'bandUnreachable', + ); + } + task.records = report.records; + if (!report.complete || await _backlogRemains(liveEngine)) { + return ShortcutSyncResult('partial', records: report.records); + } + return await _derive(task); + } finally { + _onForegroundCommitFailure = null; + task.onStop = null; + progress.cancel(); + await radio.cancel(); + } + } + + final lease = BandOwnership.tryAcquireHeadless(); + if (lease == null) return const ShortcutSyncResult('alreadyRunning'); + try { + return await _headless(task, paired); + } finally { + BandOwnership.release(lease); + } + } + + static Future _headless( + ShortcutSyncTask task, + PairedDevice paired, + ) async { + final engine = createHeadlessSyncEngine( + onCommitted: (count) => task.update( + 'syncing', + records: task.records + count, + batches: task.batches + 1, + ), + onCommitError: (_) => task.stop('failed'), + ); + task.onStop = () { + // Keep the gate and lease until the engine's serialized teardown completes. + unawaited( + engine.disconnect().catchError( + (Object error) => debugPrint('[shortcut-sync] teardown: $error'), + ), + ); + }; + var radioConnected = false; + final radio = BluetoothDevice.fromId(paired.remoteId).connectionState + .listen((state) { + if (state == BluetoothConnectionState.connected) { + radioConnected = true; + if (task.phase == 'connecting') task.update('initializing'); + } + }); + try { + task.update('connecting'); + final connected = await engine.connectToRemoteId( + paired.remoteId, + generationHint: paired.generation, + ); + if (task.stopped) return task.expired; + final blocker = _blockerResult(engine.bluetoothBlocker); + if (blocker != null) return blocker; + if (!connected) { + if (BandOwnership.foregroundIntent) { + return const ShortcutSyncResult('alreadyRunning'); + } + return ShortcutSyncResult( + radioConnected ? 'failed' : 'bandUnreachable', + ); + } + task.update('syncing'); + var previousBatches = 0; + for (var session = 0; session < 20 && !task.stopped; session++) { + final report = await engine.runSync(timeout: task.remaining); + if (task.stopped) return task.expired; + final backlogRemains = await _backlogRemains(engine); + if (report.complete && !backlogRemains) { + await engine.disconnect(); + task.onStop = null; + return await _derive(task); + } + if (!report.complete || + report.batches <= previousBatches || + engine.historyStuckThisSession || + !engine.isConnected) { + break; + } + previousBatches = report.batches; + // HISTORY_COMPLETE can end one session while the advertised backlog still remains. + if (!task.stopped) await engine.requestHistorySync(); + } + return ShortcutSyncResult('partial', records: task.records); + } finally { + task.onStop = null; + try { + await engine.disconnect(); + } finally { + await radio.cancel(); + } + } + } + + static Future _derive(ShortcutSyncTask task) async { + if (task.stopped) return task.expired; + task.update('processing'); + final profile = await loadHeadlessProfile(); + if (task.stopped) return task.expired; + await DerivationEngine( + log: (line) => debugPrint('[shortcut-derive] $line'), + background: true, + ).run(profile, heavy: false); + if (task.stopped) return task.expired; + await WidgetService.refresh( + LocalRepositoryImpl(getProfileMap: () => profile.toMap()), + ); + return ShortcutSyncResult('complete', records: task.records); + } + + static Future _backlogRemains(BleEngine engine) async { + final frontier = await LocalDb.getCursorInt('rec_ts_hw'); + final newest = engine.strapHistoryNewestTs; + return frontier != null && newest != null && newest - frontier > 300; + } +} diff --git a/lib/sync/shortcut_sync_task.dart b/lib/sync/shortcut_sync_task.dart new file mode 100644 index 00000000..244b44be --- /dev/null +++ b/lib/sync/shortcut_sync_task.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +class ShortcutSyncResult { + final String status; + final int records; + const ShortcutSyncResult(this.status, {this.records = 0}); + + Map toMap() => {'status': status, 'records': records}; +} + +/// The caller's deadline must not release BLE ownership before cleanup finishes. +class ShortcutSyncTask { + final String id; + final Duration budget; + final void Function(Map)? onProgress; + final Stopwatch _clock = Stopwatch()..start(); + final _stopped = Completer(); + String phase = 'starting'; + int records = 0; + int batches = 0; + void Function()? onStop; + + ShortcutSyncTask(this.id, this.budget, {this.onProgress}); + + bool get stopped => _stopped.isCompleted; + Duration get remaining { + final value = budget - _clock.elapsed; + return value.isNegative ? Duration.zero : value; + } + + void update(String phase, {int? records, int? batches}) { + if (stopped) return; + this.phase = phase; + this.records = records ?? this.records; + this.batches = batches ?? this.batches; + onProgress?.call({ + 'id': id, + 'phase': phase, + 'records': this.records, + 'batches': this.batches, + }); + } + + ShortcutSyncResult get expired => ShortcutSyncResult( + phase == 'connecting' + ? 'bandUnreachable' + : phase == 'syncing' || phase == 'processing' + ? 'partial' + : 'timedOut', + records: records, + ); + + void stop([String? status]) { + if (stopped) return; + _stopped.complete( + status == null ? expired : ShortcutSyncResult(status, records: records), + ); + onStop?.call(); + } + + Future waitFor(Future work) async { + final timer = Timer(remaining, stop); + try { + return await Future.any([work, _stopped.future]); + } finally { + timer.cancel(); + } + } +} diff --git a/test/app_state_shortcut_sync_test.dart b/test/app_state_shortcut_sync_test.dart new file mode 100644 index 00000000..8a0ec25f --- /dev/null +++ b/test/app_state_shortcut_sync_test.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/sync/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; + + _ConnectedEngine() : super(onRecord: (_, _) async {}, onState: (_) {}); + + @override + bool get isConnected => true; + + @override + Duration get sinceLastRx => Duration.zero; + + @override + Future requestHistorySync() async => requests++; + + @override + Future runSync({ + Duration timeout = const Duration(seconds: 600), + }) { + runs++; + return reply.future; + } + + @override + Future disconnect() async => disconnects++; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_app_state_shortcut_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('concurrent callers join the app-owned sync burst', () async { + final engine = _ConnectedEngine(); + final app = AppState.forTesting(engine: engine)..initialized = true; + addTearDown(app.dispose); + final first = app.syncForShortcut( + ShortcutSyncTask('first', const Duration(seconds: 5)), + ); + final second = app.syncForShortcut( + ShortcutSyncTask('second', const Duration(seconds: 5)), + ); + while (engine.runs == 0) { + await Future.delayed(const Duration(milliseconds: 5)); + } + expect(engine.requests, 1); + expect(engine.runs, 1); + engine.reply.complete(SyncReport(0, 0, true)); + expect((await first).complete, isTrue); + expect((await second).complete, isTrue); + expect(engine.disconnects, 0); + }); + + test( + 'cancellation while waiting for initialization never touches the band', + () async { + final engine = _ConnectedEngine(); + final app = AppState.forTesting(engine: engine); + addTearDown(app.dispose); + final task = ShortcutSyncTask('starting', const Duration(seconds: 5)); + final work = app.syncForShortcut(task); + task.stop('cancelled'); + expect((await work).complete, isFalse); + expect(engine.requests, 0); + expect(engine.disconnects, 0); + }, + ); + + test( + 'cancellation while the app is busy never starts another burst', + () async { + final engine = _ConnectedEngine(); + final app = AppState.forTesting(engine: engine) + ..initialized = true + ..busy = true; + addTearDown(app.dispose); + final task = ShortcutSyncTask('busy', const Duration(seconds: 5)); + final work = app.syncForShortcut(task); + expect(task.phase, 'waiting'); + task.stop('cancelled'); + expect((await work).complete, isFalse); + expect(engine.requests, 0); + expect(engine.disconnects, 0); + }, + ); + + test('cancelling a waiter preserves the app-owned transfer', () async { + final engine = _ConnectedEngine(); + final app = AppState.forTesting(engine: engine)..initialized = true; + addTearDown(app.dispose); + final task = ShortcutSyncTask('cancel', const Duration(seconds: 5)); + final work = app.syncForShortcut(task); + while (engine.runs == 0) { + await Future.delayed(const Duration(milliseconds: 5)); + } + task.stop('cancelled'); + expect(engine.disconnects, 0); + engine.reply.complete(SyncReport(0, 0, true)); + await work; + expect(engine.disconnects, 0); + expect(task.stopped, isTrue); + }); +} diff --git a/test/ios_shortcut_sync_test.dart b/test/ios_shortcut_sync_test.dart new file mode 100644 index 00000000..306df198 --- /dev/null +++ b/test/ios_shortcut_sync_test.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/state/prefs.dart'; +import 'package:openstrap_edge/sync/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: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( + '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/shortcut_sync_task_test.dart b/test/shortcut_sync_task_test.dart new file mode 100644 index 00000000..c0c8e080 --- /dev/null +++ b/test/shortcut_sync_task_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/sync/headless_gate.dart'; +import 'package:openstrap_edge/sync/shortcut_sync_task.dart'; + +void main() { + setUp(HeadlessSyncGate.resetForTest); + + test('returns completed work without cancelling it', () async { + final task = ShortcutSyncTask('one', const Duration(seconds: 1)); + var stopped = false; + task.onStop = () => stopped = true; + final result = await task.waitFor( + Future.value(const ShortcutSyncResult('complete', records: 12)), + ); + expect(result.toMap(), {'status': 'complete', 'records': 12}); + expect(stopped, isFalse); + }); + + test( + 'a connection deadline is distinguishable from a partial drain', + () async { + for (final phase in [ + 'starting', + 'initializing', + 'connecting', + 'syncing', + 'processing', + ]) { + final task = ShortcutSyncTask('one', Duration.zero) + ..update(phase, records: 9); + final result = await task.waitFor( + Completer().future, + ); + expect(result.status, switch (phase) { + 'starting' || 'initializing' => 'timedOut', + 'connecting' => 'bandUnreachable', + _ => 'partial', + }); + expect(result.records, 9); + } + }, + ); + + test( + 'cancellation stops once and does not report subsequent progress', + () async { + final updates = >[]; + final task = ShortcutSyncTask( + 'one', + const Duration(seconds: 1), + onProgress: updates.add, + ); + var stops = 0; + task.onStop = () => stops++; + task.update('syncing', records: 15, batches: 2); + final result = task.waitFor(Completer().future); + task.stop('cancelled'); + task.stop('failed'); + task.update('syncing', records: 40); + expect((await result).status, 'cancelled'); + expect(stops, 1); + expect(updates, [ + {'id': 'one', 'phase': 'syncing', 'records': 15, 'batches': 2}, + ]); + }, + ); + + test( + 'deadline returns without releasing the ownership gate during cleanup', + () async { + final cleanup = Completer(); + final task = ShortcutSyncTask('one', Duration.zero)..update('syncing'); + final work = HeadlessSyncGate.tryRun('shortcut', () => cleanup.future); + final result = await task.waitFor(work.then((value) => value!)); + expect(result.status, 'partial'); + expect(HeadlessSyncGate.busy, isTrue); + var secondRan = false; + expect( + await HeadlessSyncGate.tryRun('other', () async => secondRan = true), + isNull, + ); + expect(secondRan, isFalse); + cleanup.complete(const ShortcutSyncResult('partial')); + await work; + expect(HeadlessSyncGate.busy, isFalse); + }, + ); + + test('unexpected failures are not converted to connectivity skips', () async { + final task = ShortcutSyncTask('one', const Duration(seconds: 1)); + final work = Future.error(StateError('storage failed')); + await expectLater(task.waitFor(work), throwsStateError); + }); +} From d99a63041245b00c1b18d5e030b7369064b97e5b Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 12 Sep 2026 18:05:37 +0200 Subject: [PATCH 2/2] feat(ios): add cancellable long-running sync intent Expose Sync Data (Long Running) on iOS 27 with extended execution, system-managed progress, and cancellation through the shared sync bridge. Keep the ordinary action and deployment target unchanged, and cover the additional intent with native and system-invocation tests. --- guides/IOS_SHORTCUTS.md | 10 +- ios/Runner.xcodeproj/project.pbxproj | 8 ++ ios/Runner/LongSyncDataIntent.swift | 92 +++++++++++++++++++ ios/RunnerTests/LongSyncDataIntentTests.swift | 89 ++++++++++++++++++ ios/ShortcutUITests/ShortcutUITests.swift | 6 ++ 5 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 ios/Runner/LongSyncDataIntent.swift create mode 100644 ios/RunnerTests/LongSyncDataIntentTests.swift diff --git a/guides/IOS_SHORTCUTS.md b/guides/IOS_SHORTCUTS.md index 8f152af8..2c59b385 100644 --- a/guides/IOS_SHORTCUTS.md +++ b/guides/IOS_SHORTCUTS.md @@ -17,6 +17,14 @@ For a personal automation, choose a Time of Day trigger, select **Run Immediatel The ordinary action has a 25-second native deadline, including Flutter startup. The Dart transfer receives a slightly shorter budget so it can report partial progress before that deadline. An iOS interruption can still prevent a result from being returned. Apple's ordinary App Intent execution budget is approximately 30 seconds; see [LongRunningIntent](https://developer.apple.com/documentation/appintents/longrunningintent). +## Sync Data (Long Running) + +On iOS 27 and later, builds made with Xcode 27 or later also expose **Sync Data (Long Running)**. It uses Apple's `LongRunningIntent` and `CancellableIntent` in the main app process, with the same sync bridge, persistence path, and **Ignore Connectivity Errors** option. The ordinary action remains available on iOS 16 and later; the app's deployment target is unchanged. + +The long-running action requests extended execution through `performBackgroundTask`. Its own deadline is ten minutes, including startup; this is a limit imposed by Edge, not a promise that iOS will grant ten minutes. System cancellation and timeouts cancel the matching sync request and preserve committed data. + +The system manages the progress Live Activity and its stop control. Progress counts actual saved batches and stays indeterminate because the band does not provide a reliable total batch count. Only a completed sync marks progress complete; partial, skipped, and already-running results do not. The connectivity-error option does not suppress this system UI. See [Apple's long-running intent walkthrough](https://developer.apple.com/videos/play/wwdc2026/345/). + ## Lifecycle and data safety For an interactive fallback, **Open Edge and Sync** brings the app forward and invokes the same sync bridge. It is not intended for unattended locked-device automations. The action itself remains bounded, but an app-owned session can keep catching up while Edge is open. @@ -29,7 +37,7 @@ Cancellation or a deadline stops an action-owned connection. Its gate and lease ## Validation -The Dart task tests cover completion, deadline classification, cancellation, progress suppression after cancellation, and ownership retention during cleanup. The iOS Runner tests cover bridge readiness, concurrent requests, late replies, cancellation, malformed responses, and the exact connectivity-error allowlist. A clean-simulator integration test calls through the real Flutter bridge and expects a missing-pairing error rather than success. +The Dart task tests cover completion, deadline classification, cancellation, progress suppression after cancellation, and ownership retention during cleanup. The iOS Runner tests cover bridge readiness, concurrent requests, late replies, cancellation, malformed responses, and the exact connectivity-error allowlist. Long-running tests also cover its extended budget, truthful progress, and cancellation before dispatch. A clean-simulator integration test calls through the real Flutter bridge and expects a missing-pairing error rather than success. The separate **ShortcutIntents** Xcode scheme uses Apple's `AppIntentsTesting` framework on iOS 27 to exercise background invocation, relaunch after termination, and the foreground fallback through the system's intent infrastructure. It does not replace the ordinary **Runner** scheme or raise the application's deployment target. Use a clean simulator and a separate bundle identifier so these tests cannot use a real pairing or change an existing installation: diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 838823c6..89d1f026 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ 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 */; }; + 5A0C00000000000000000003 /* LongSyncDataIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A0C00000000000000000004 /* LongSyncDataIntent.swift */; }; + 5A0C00000000000000000005 /* LongSyncDataIntentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A0C00000000000000000006 /* LongSyncDataIntentTests.swift */; }; 60DE9D949573401269D6DF2E /* HealthRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46A2B400A52A2CA90242C195 /* HealthRoutes.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 */; }; @@ -126,6 +128,8 @@ 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 = ""; }; + 5A0C00000000000000000004 /* LongSyncDataIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongSyncDataIntent.swift; sourceTree = ""; }; + 5A0C00000000000000000006 /* LongSyncDataIntentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongSyncDataIntentTests.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 = ""; }; @@ -239,6 +243,7 @@ isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + 5A0C00000000000000000006 /* LongSyncDataIntentTests.swift */, ); path = RunnerTests; sourceTree = ""; @@ -336,6 +341,7 @@ ACCE55E7000000000000F11E /* AccessorySetup.swift */, 0BGTASK00000000000000002 /* BgSyncScheduler.swift */, 5A0C00000000000000000002 /* ShortcutSyncBridge.swift */, + 5A0C00000000000000000004 /* LongSyncDataIntent.swift */, 46A2B400A52A2CA90242C195 /* HealthRoutes.swift */, A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */, ); @@ -726,6 +732,7 @@ buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + 5A0C00000000000000000005 /* LongSyncDataIntentTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -766,6 +773,7 @@ ACCE55E7000000000000B11D /* AccessorySetup.swift in Sources */, 0BGTASK00000000000000001 /* BgSyncScheduler.swift in Sources */, 5A0C00000000000000000001 /* ShortcutSyncBridge.swift in Sources */, + 5A0C00000000000000000003 /* LongSyncDataIntent.swift in Sources */, 60DE9D949573401269D6DF2E /* HealthRoutes.swift in Sources */, B9D2F406183A5C7E92B1D3F5 /* HealthKitSleepWriter.swift in Sources */, ); diff --git a/ios/Runner/LongSyncDataIntent.swift b/ios/Runner/LongSyncDataIntent.swift new file mode 100644 index 00000000..8552ad9e --- /dev/null +++ b/ios/Runner/LongSyncDataIntent.swift @@ -0,0 +1,92 @@ +#if compiler(>=6.4) +import AppIntents +import Foundation + +@available(iOS 27.0, *) +struct LongSyncDataIntent: LongRunningIntent, CancellableIntent { + static var title: LocalizedStringResource = "Sync Data (Long Running)" + static var description = IntentDescription( + "Sync a larger band backlog with system-managed progress and cancellation. iOS may interrupt the task; saved data is retained.") + static var supportedModes: IntentModes = .background + static var allowedExecutionTargets: IntentExecutionTargets = .main + static var authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed + + @Parameter(title: "Ignore Connectivity Errors", description: + "Skip when Bluetooth is unavailable or the band cannot be reached. Other errors and system progress UI are not suppressed.", default: false) + var ignoreConnectivityErrors: Bool + + static var parameterSummary: some ParameterSummary { + Summary("Sync a larger band backlog") { \.$ignoreConnectivityErrors } + } + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + let id = UUID().uuidString + let bridge = ShortcutSyncBridge.shared + let taskProgress = progress + LongSyncProgress.start(taskProgress) + let message = try await performBackgroundTask { + try await syncMessage(using: bridge, id: id, progress: taskProgress) + } onCancel: { reason in + if !taskProgress.isCancelled { taskProgress.cancel() } + Task { @MainActor in + bridge.cancel(id: id, code: reason == .timeout ? "timedOut" : "cancelled") + } + } + return .result(value: message) + } + + @MainActor + func syncMessage(using bridge: ShortcutSyncBridge, id: String, + progress: Progress) async throws -> String { + guard !progress.isCancelled else { throw ShortcutSyncFailure(code: "cancelled") } + do { + let reply = try await bridge.sync(id: id, timeout: 600, progress: { update in + LongSyncProgress.update(progress, with: update) + }) + if reply.status == "complete" { + LongSyncProgress.finish(progress) + } + progress.localizedAdditionalDescription = reply.message + return reply.message + } catch let error as ShortcutSyncFailure where ignoreConnectivityErrors && error.canIgnore { + let message = "Skipped: \(error.localizedDescription)" + progress.localizedAdditionalDescription = message + return message + } + } +} + +@available(iOS 27.0, *) +@MainActor +enum LongSyncProgress { + static func start(_ progress: Progress) { + // The band does not provide a reliable total batch count in advance. + progress.totalUnitCount = -1 + progress.completedUnitCount = 0 + progress.localizedDescription = "Syncing band data" + progress.localizedAdditionalDescription = "Starting Edge" + } + + static func update(_ progress: Progress, with update: [String: Any]) { + guard !progress.isCancelled, let phase = update["phase"] as? String else { return } + let batches = max(0, update["batches"] as? Int ?? 0) + progress.completedUnitCount = max(progress.completedUnitCount, Int64(batches)) + switch phase { + case "starting": progress.localizedAdditionalDescription = "Starting Edge" + case "waiting": progress.localizedAdditionalDescription = "Waiting for Edge" + case "connecting": progress.localizedAdditionalDescription = "Connecting to band" + case "initializing": progress.localizedAdditionalDescription = "Preparing band connection" + case "syncing": progress.localizedAdditionalDescription = "Saving band data (\(batches) batches)" + case "processing": progress.localizedAdditionalDescription = "Refreshing recent metrics" + default: break + } + } + + static func finish(_ progress: Progress) { + guard !progress.isCancelled else { return } + progress.totalUnitCount = max(1, progress.completedUnitCount) + progress.completedUnitCount = progress.totalUnitCount + } +} +#endif diff --git a/ios/RunnerTests/LongSyncDataIntentTests.swift b/ios/RunnerTests/LongSyncDataIntentTests.swift new file mode 100644 index 00000000..4830ed28 --- /dev/null +++ b/ios/RunnerTests/LongSyncDataIntentTests.swift @@ -0,0 +1,89 @@ +#if compiler(>=6.4) +import AppIntents +import Foundation +import Flutter +import XCTest +@testable import Runner + +@MainActor +final class LongSyncDataIntentTests: XCTestCase { + func testProgressUsesRealBatchesWithoutAnInventedPercentage() throws { + guard #available(iOS 27.0, *) else { throw XCTSkip("Requires iOS 27") } + let progress = Progress(totalUnitCount: 0) + LongSyncProgress.start(progress) + XCTAssertLessThan(progress.totalUnitCount, 0) + LongSyncProgress.update(progress, with: ["phase": "syncing", "batches": 8]) + XCTAssertEqual(progress.completedUnitCount, 8) + XCTAssertLessThan(progress.totalUnitCount, 0) + LongSyncProgress.update(progress, with: ["phase": "syncing", "batches": 3]) + XCTAssertEqual(progress.completedUnitCount, 8) + LongSyncProgress.update(progress, with: ["phase": "processing", "batches": 8]) + XCTAssertLessThan(progress.totalUnitCount, 0) + LongSyncProgress.finish(progress) + XCTAssertEqual(progress.fractionCompleted, 1) + } + + func testCancelledProgressCannotBecomeCompleted() throws { + guard #available(iOS 27.0, *) else { throw XCTSkip("Requires iOS 27") } + let progress = Progress(totalUnitCount: 0) + LongSyncProgress.start(progress) + progress.cancel() + LongSyncProgress.update(progress, with: ["phase": "syncing", "batches": 9]) + LongSyncProgress.finish(progress) + XCTAssertEqual(progress.completedUnitCount, 0) + XCTAssertLessThan(progress.totalUnitCount, 0) + } + + func testLongRunningResultsAndSuppressionRemainTruthful() async throws { + guard #available(iOS 27.0, *) else { throw XCTSkip("Requires iOS 27") } + for status in ["complete", "partial", "alreadyRunning", "bluetoothUnavailable", + "bandUnreachable", "permissionDenied", "notPaired", "failed"] { + for ignore in [false, true] { + let bridge = ShortcutSyncBridge { method, args, reply in + XCTAssertEqual(method, "run") + let budget = (args as! [String: Any])["budgetMs"] as! Int + XCTAssertGreaterThan(budget, 590_000) + XCTAssertLessThan(budget, 600_000) + reply(["status": status, "records": 3]) + } + bridge.receive(FlutterMethodCall(methodName: "ready", arguments: nil)) { _ in } + var intent = LongSyncDataIntent() + intent.ignoreConnectivityErrors = ignore + let progress = Progress(totalUnitCount: 0) + LongSyncProgress.start(progress) + let success = ["complete", "partial", "alreadyRunning"].contains(status) + let suppressed = ignore && ["bluetoothUnavailable", "bandUnreachable"].contains(status) + do { + let message = try await intent.syncMessage(using: bridge, id: "long", progress: progress) + XCTAssertTrue(success || suppressed, "Unexpected success for \(status)") + XCTAssertEqual(message.hasPrefix("Skipped:"), suppressed) + if status == "complete" { + XCTAssertEqual(progress.fractionCompleted, 1) + } else { + XCTAssertLessThan(progress.totalUnitCount, 0) + } + } catch let error as ShortcutSyncFailure { + XCTAssertFalse(success || suppressed) + XCTAssertEqual(error.code, status) + } + } + } + } + + func testSystemCancellationBeforeRegistrationNeverStartsDartSync() async throws { + guard #available(iOS 27.0, *) else { throw XCTSkip("Requires iOS 27") } + var dispatched = false + let bridge = ShortcutSyncBridge { _, _, _ in dispatched = true } + bridge.receive(FlutterMethodCall(methodName: "ready", arguments: nil)) { _ in } + let progress = Progress(totalUnitCount: 0) + progress.cancel() + do { + _ = try await LongSyncDataIntent().syncMessage(using: bridge, id: "cancelled", progress: progress) + XCTFail("Cancellation before registration must not be lost") + } catch let error as ShortcutSyncFailure { + XCTAssertEqual(error.code, "cancelled") + } + XCTAssertFalse(dispatched) + } +} +#endif diff --git a/ios/ShortcutUITests/ShortcutUITests.swift b/ios/ShortcutUITests/ShortcutUITests.swift index 25cd15fc..ae7559fc 100644 --- a/ios/ShortcutUITests/ShortcutUITests.swift +++ b/ios/ShortcutUITests/ShortcutUITests.swift @@ -26,6 +26,12 @@ final class ShortcutUITests: XCTestCase { XCTAssertNotEqual(app.state, .runningForeground) } + func testLongSyncUsesSystemExecutionWithoutSuppressingPairingErrors() async throws { + backgroundApp() + try await expectPairingError("LongSyncDataIntent") + XCTAssertNotEqual(app.state, .runningForeground) + } + func testForegroundFallbackUsesTheSameSyncEntryPoint() async throws { backgroundApp() do {