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