From 1a86f289e3e76a4375d717b1fa440d24edaf664b Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Tue, 12 May 2026 13:25:37 +0200 Subject: [PATCH 01/23] Add JetpackWatch Watch App target scaffolding New watchOS 26.5 target embedded in the Jetpack iOS app. App Groups capability (group.org.wordpress.jetpack.voicenotes) and microphone usage description added. Default Xcode template content; real implementation lands in subsequent commits. --- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../JetpackWatch Watch App/ContentView.swift | 25 + .../JetpackWatch Watch App.entitlements | 10 + .../JetpackWatchApp.swift | 18 + .../JetpackWatch_Watch_AppTests.swift | 20 + .../JetpackWatch_Watch_AppUITests.swift | 44 ++ ...ackWatch_Watch_AppUITestsLaunchTests.swift | 36 + WordPress/WordPress.xcodeproj/project.pbxproj | 697 +++++++++++++++++- 10 files changed, 879 insertions(+), 1 deletion(-) create mode 100644 WordPress/JetpackWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 WordPress/JetpackWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 WordPress/JetpackWatch Watch App/Assets.xcassets/Contents.json create mode 100644 WordPress/JetpackWatch Watch App/ContentView.swift create mode 100644 WordPress/JetpackWatch Watch App/JetpackWatch Watch App.entitlements create mode 100644 WordPress/JetpackWatch Watch App/JetpackWatchApp.swift create mode 100644 WordPress/JetpackWatch Watch AppTests/JetpackWatch_Watch_AppTests.swift create mode 100644 WordPress/JetpackWatch Watch AppUITests/JetpackWatch_Watch_AppUITests.swift create mode 100644 WordPress/JetpackWatch Watch AppUITests/JetpackWatch_Watch_AppUITestsLaunchTests.swift diff --git a/WordPress/JetpackWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/WordPress/JetpackWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000000..eb8789700816 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/JetpackWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/WordPress/JetpackWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..49c81cd8c4c5 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/JetpackWatch Watch App/Assets.xcassets/Contents.json b/WordPress/JetpackWatch Watch App/Assets.xcassets/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/JetpackWatch Watch App/ContentView.swift b/WordPress/JetpackWatch Watch App/ContentView.swift new file mode 100644 index 000000000000..b264e807115b --- /dev/null +++ b/WordPress/JetpackWatch Watch App/ContentView.swift @@ -0,0 +1,25 @@ +// +// ContentView.swift +// JetpackWatch Watch App +// +// Created by Ricardo Ortiz on 12/05/2026. +// Copyright © 2026 WordPress. All rights reserved. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/WordPress/JetpackWatch Watch App/JetpackWatch Watch App.entitlements b/WordPress/JetpackWatch Watch App/JetpackWatch Watch App.entitlements new file mode 100644 index 000000000000..88907e5f5adc --- /dev/null +++ b/WordPress/JetpackWatch Watch App/JetpackWatch Watch App.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.wordpress.jetpack.voicenotes + + + diff --git a/WordPress/JetpackWatch Watch App/JetpackWatchApp.swift b/WordPress/JetpackWatch Watch App/JetpackWatchApp.swift new file mode 100644 index 000000000000..28ab0f65c837 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/JetpackWatchApp.swift @@ -0,0 +1,18 @@ +// +// JetpackWatchApp.swift +// JetpackWatch Watch App +// +// Created by Ricardo Ortiz on 12/05/2026. +// Copyright © 2026 WordPress. All rights reserved. +// + +import SwiftUI + +@main +struct JetpackWatch_Watch_AppApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/WordPress/JetpackWatch Watch AppTests/JetpackWatch_Watch_AppTests.swift b/WordPress/JetpackWatch Watch AppTests/JetpackWatch_Watch_AppTests.swift new file mode 100644 index 000000000000..d5b060036b6d --- /dev/null +++ b/WordPress/JetpackWatch Watch AppTests/JetpackWatch_Watch_AppTests.swift @@ -0,0 +1,20 @@ +// +// JetpackWatch_Watch_AppTests.swift +// JetpackWatch Watch AppTests +// +// Created by Ricardo Ortiz on 12/05/2026. +// Copyright © 2026 WordPress. All rights reserved. +// + +import Testing +@testable import JetpackWatch_Watch_App + +struct JetpackWatch_Watch_AppTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + // Swift Testing Documentation + // https://developer.apple.com/documentation/testing + } + +} diff --git a/WordPress/JetpackWatch Watch AppUITests/JetpackWatch_Watch_AppUITests.swift b/WordPress/JetpackWatch Watch AppUITests/JetpackWatch_Watch_AppUITests.swift new file mode 100644 index 000000000000..c93cff945c49 --- /dev/null +++ b/WordPress/JetpackWatch Watch AppUITests/JetpackWatch_Watch_AppUITests.swift @@ -0,0 +1,44 @@ +// +// JetpackWatch_Watch_AppUITests.swift +// JetpackWatch Watch AppUITests +// +// Created by Ricardo Ortiz on 12/05/2026. +// Copyright © 2026 WordPress. All rights reserved. +// + +import XCTest + +final class JetpackWatch_Watch_AppUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + // XCUIAutomation Documentation + // https://developer.apple.com/documentation/xcuiautomation + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/WordPress/JetpackWatch Watch AppUITests/JetpackWatch_Watch_AppUITestsLaunchTests.swift b/WordPress/JetpackWatch Watch AppUITests/JetpackWatch_Watch_AppUITestsLaunchTests.swift new file mode 100644 index 000000000000..91adedc858fe --- /dev/null +++ b/WordPress/JetpackWatch Watch AppUITests/JetpackWatch_Watch_AppUITestsLaunchTests.swift @@ -0,0 +1,36 @@ +// +// JetpackWatch_Watch_AppUITestsLaunchTests.swift +// JetpackWatch Watch AppUITests +// +// Created by Ricardo Ortiz on 12/05/2026. +// Copyright © 2026 WordPress. All rights reserved. +// + +import XCTest + +final class JetpackWatch_Watch_AppUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + // XCUIAutomation Documentation + // https://developer.apple.com/documentation/xcuiautomation + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index ccc9e0f7a4a2..fee89f252480 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -193,6 +193,7 @@ 93F2E5401E9E5A180050D489 /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 93F2E53F1E9E5A180050D489 /* libsqlite3.tbd */; }; 93F2E5421E9E5A350050D489 /* QuickLook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93F2E5411E9E5A350050D489 /* QuickLook.framework */; }; 93F2E5441E9E5A570050D489 /* CoreSpotlight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93F2E5431E9E5A570050D489 /* CoreSpotlight.framework */; }; + 9425208A2FB33FC00027445A /* JetpackWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 9425206A2FB33FBE0027445A /* JetpackWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; A01C542E0E24E88400D411F2 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A01C542D0E24E88400D411F2 /* SystemConfiguration.framework */; }; B5AA54D51A8E7510003BDD12 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5AA54D41A8E7510003BDD12 /* WebKit.framework */; }; E10B3652158F2D3F00419A93 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E10B3651158F2D3F00419A93 /* QuartzCore.framework */; }; @@ -434,6 +435,27 @@ remoteGlobalIDString = 932225A61C7CE50300443B02; remoteInfo = WordPressShare; }; + 942520772FB33FC00027445A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 942520692FB33FBE0027445A; + remoteInfo = "JetpackWatch Watch App"; + }; + 942520812FB33FC00027445A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 942520692FB33FBE0027445A; + remoteInfo = "JetpackWatch Watch App"; + }; + 942520882FB33FC00027445A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 942520692FB33FBE0027445A; + remoteInfo = "JetpackWatch Watch App"; + }; E16AB93E14D978520047A2E5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; @@ -639,6 +661,17 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + 9425208B2FB33FC00027445A /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 9425208A2FB33FC00027445A /* JetpackWatch Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; FABB26402602FC2C00C8785C /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -869,6 +902,9 @@ 93F2E5521E9E5CF00050D489 /* VideoToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VideoToolbox.framework; path = System/Library/Frameworks/VideoToolbox.framework; sourceTree = SDKROOT; }; 93FA0F0118E451A80007903B /* LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = ""; }; 93FA0F0218E451A80007903B /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = README.md; path = ../README.md; sourceTree = ""; }; + 9425206A2FB33FBE0027445A /* JetpackWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "JetpackWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 942520762FB33FC00027445A /* JetpackWatch Watch AppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "JetpackWatch Watch AppTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 942520802FB33FC00027445A /* JetpackWatch Watch AppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "JetpackWatch Watch AppUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 98FB6E9F23074CE5002DDC8D /* Common.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Common.xcconfig; sourceTree = ""; }; A01C542D0E24E88400D411F2 /* SystemConfiguration.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; A20971B419B0BC390058F395 /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/Localizable.strings"; sourceTree = ""; }; @@ -1250,6 +1286,21 @@ path = WordPressKitTests; sourceTree = ""; }; + 9425206B2FB33FBE0027445A /* JetpackWatch Watch App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "JetpackWatch Watch App"; + sourceTree = ""; + }; + 942520792FB33FC00027445A /* JetpackWatch Watch AppTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "JetpackWatch Watch AppTests"; + sourceTree = ""; + }; + 942520832FB33FC00027445A /* JetpackWatch Watch AppUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "JetpackWatch Watch AppUITests"; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1464,6 +1515,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 942520672FB33FBE0027445A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 942520732FB33FC00027445A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9425207D2FB33FC00027445A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E16AB92614D978240047A2E5 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1592,6 +1664,9 @@ 0C3313B72E0439A8000C3760 /* Miniature.app */, 0C3313C32E0439A9000C3760 /* MiniatureTests.xctest */, 4A8280FD2E5FE9B60037E180 /* WordPressKitTests.xctest */, + 9425206A2FB33FBE0027445A /* JetpackWatch Watch App.app */, + 942520762FB33FC00027445A /* JetpackWatch Watch AppTests.xctest */, + 942520802FB33FC00027445A /* JetpackWatch Watch AppUITests.xctest */, ); name = Products; sourceTree = ""; @@ -1634,6 +1709,9 @@ 932225A81C7CE50300443B02 /* WordPressShareExtension */, 8511CFB71C607A7000B7CEED /* WordPressScreenshotGeneration */, FAF64BC82637DF0600E8A1DF /* JetpackScreenshotGeneration */, + 9425206B2FB33FBE0027445A /* JetpackWatch Watch App */, + 942520792FB33FC00027445A /* JetpackWatch Watch AppTests */, + 942520832FB33FC00027445A /* JetpackWatch Watch AppUITests */, 29B97323FDCFA39411CA2CEA /* Frameworks */, 19C28FACFE9D520D11CA2CBB /* Products */, 93FA0F0118E451A80007903B /* LICENSE */, @@ -2448,6 +2526,74 @@ productReference = 932225A71C7CE50300443B02 /* WordPressShareExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + 942520692FB33FBE0027445A /* JetpackWatch Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 942520952FB33FC00027445A /* Build configuration list for PBXNativeTarget "JetpackWatch Watch App" */; + buildPhases = ( + 942520662FB33FBE0027445A /* Sources */, + 942520672FB33FBE0027445A /* Frameworks */, + 942520682FB33FBE0027445A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 9425206B2FB33FBE0027445A /* JetpackWatch Watch App */, + ); + name = "JetpackWatch Watch App"; + packageProductDependencies = ( + ); + productName = "JetpackWatch Watch App"; + productReference = 9425206A2FB33FBE0027445A /* JetpackWatch Watch App.app */; + productType = "com.apple.product-type.application"; + }; + 942520752FB33FC00027445A /* JetpackWatch Watch AppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 942520962FB33FC00027445A /* Build configuration list for PBXNativeTarget "JetpackWatch Watch AppTests" */; + buildPhases = ( + 942520722FB33FC00027445A /* Sources */, + 942520732FB33FC00027445A /* Frameworks */, + 942520742FB33FC00027445A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 942520782FB33FC00027445A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 942520792FB33FC00027445A /* JetpackWatch Watch AppTests */, + ); + name = "JetpackWatch Watch AppTests"; + packageProductDependencies = ( + ); + productName = "JetpackWatch Watch AppTests"; + productReference = 942520762FB33FC00027445A /* JetpackWatch Watch AppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 9425207F2FB33FC00027445A /* JetpackWatch Watch AppUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 942520972FB33FC00027445A /* Build configuration list for PBXNativeTarget "JetpackWatch Watch AppUITests" */; + buildPhases = ( + 9425207C2FB33FC00027445A /* Sources */, + 9425207D2FB33FC00027445A /* Frameworks */, + 9425207E2FB33FC00027445A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 942520822FB33FC00027445A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 942520832FB33FC00027445A /* JetpackWatch Watch AppUITests */, + ); + name = "JetpackWatch Watch AppUITests"; + packageProductDependencies = ( + ); + productName = "JetpackWatch Watch AppUITests"; + productReference = 942520802FB33FC00027445A /* JetpackWatch Watch AppUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; E16AB92914D978240047A2E5 /* WordPressTest */ = { isa = PBXNativeTarget; buildConfigurationList = E16AB93D14D978240047A2E5 /* Build configuration list for PBXNativeTarget "WordPressTest" */; @@ -2486,6 +2632,7 @@ FABB26402602FC2C00C8785C /* Embed Foundation Extensions */, FABB264C2602FC2C00C8785C /* Copy Gutenberg JS */, 4AD9555E2C21716A00D0EEFA /* Embed Frameworks */, + 9425208B2FB33FC00027445A /* Embed Watch Content */, ); buildRules = ( ); @@ -2498,6 +2645,7 @@ 80F6D05F28EE88FC00953C1A /* PBXTargetDependency */, 4AD9555D2C21716A00D0EEFA /* PBXTargetDependency */, 3F0FDA032D9B930100CD05D6 /* PBXTargetDependency */, + 942520892FB33FC00027445A /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 0C3C988F2DA04EEF009F3BFB /* Jetpack */, @@ -2538,7 +2686,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1640; + LastSwiftUpdateCheck = 2650; LastUpgradeCheck = 2600; ORGANIZATIONNAME = WordPress; TargetAttributes = { @@ -2636,6 +2784,17 @@ }; }; }; + 942520692FB33FBE0027445A = { + CreatedOnToolsVersion = 26.5; + }; + 942520752FB33FC00027445A = { + CreatedOnToolsVersion = 26.5; + TestTargetID = 942520692FB33FBE0027445A; + }; + 9425207F2FB33FC00027445A = { + CreatedOnToolsVersion = 26.5; + TestTargetID = 942520692FB33FBE0027445A; + }; E16AB92914D978240047A2E5 = { DevelopmentTeam = PZYM8XX95Q; LastSwiftMigration = 1000; @@ -2721,6 +2880,9 @@ 0C3313B62E0439A8000C3760 /* Miniature */, 0C3313C22E0439A9000C3760 /* MiniatureTests */, 4A8280FC2E5FE9B60037E180 /* WordPressKitTests */, + 942520692FB33FBE0027445A /* JetpackWatch Watch App */, + 942520752FB33FC00027445A /* JetpackWatch Watch AppTests */, + 9425207F2FB33FC00027445A /* JetpackWatch Watch AppUITests */, ); }; /* End PBXProject section */ @@ -2892,6 +3054,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 942520682FB33FBE0027445A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 942520742FB33FC00027445A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9425207E2FB33FC00027445A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E16AB92714D978240047A2E5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -3448,6 +3631,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 942520662FB33FBE0027445A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 942520722FB33FC00027445A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9425207C2FB33FC00027445A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E16AB92514D978240047A2E5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -3607,6 +3811,21 @@ target = 932225A61C7CE50300443B02 /* WordPressShareExtension */; targetProxy = 932225AF1C7CE50300443B02 /* PBXContainerItemProxy */; }; + 942520782FB33FC00027445A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 942520692FB33FBE0027445A /* JetpackWatch Watch App */; + targetProxy = 942520772FB33FC00027445A /* PBXContainerItemProxy */; + }; + 942520822FB33FC00027445A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 942520692FB33FBE0027445A /* JetpackWatch Watch App */; + targetProxy = 942520812FB33FC00027445A /* PBXContainerItemProxy */; + }; + 942520892FB33FC00027445A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 942520692FB33FBE0027445A /* JetpackWatch Watch App */; + targetProxy = 942520882FB33FC00027445A /* PBXContainerItemProxy */; + }; E16AB93F14D978520047A2E5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 1D6058900D05DD3D006BFB54 /* WordPress */; @@ -6542,6 +6761,452 @@ }; name = "Release-Alpha"; }; + 9425208C2FB33FC00027445A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "JetpackWatch Watch App/JetpackWatch Watch App.entitlements"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = JetpackWatch; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Jetpack records your voice notes and turns them into draft blog posts."; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.automattic.jetpack; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.5; + }; + name = Debug; + }; + 9425208D2FB33FC00027445A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "JetpackWatch Watch App/JetpackWatch Watch App.entitlements"; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = JetpackWatch; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Jetpack records your voice notes and turns them into draft blog posts."; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.automattic.jetpack; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.5; + }; + name = Release; + }; + 9425208E2FB33FC00027445A /* Release-Alpha */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "JetpackWatch Watch App/JetpackWatch Watch App.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = JetpackWatch; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Jetpack records your voice notes and turns them into draft blog posts."; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.automattic.jetpack; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.5; + }; + name = "Release-Alpha"; + }; + 9425208F2FB33FC00027445A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.JetpackWatch-Watch-AppTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/JetpackWatch Watch App.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/JetpackWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 26.5; + }; + name = Debug; + }; + 942520902FB33FC00027445A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.JetpackWatch-Watch-AppTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/JetpackWatch Watch App.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/JetpackWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 26.5; + }; + name = Release; + }; + 942520912FB33FC00027445A /* Release-Alpha */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.JetpackWatch-Watch-AppTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/JetpackWatch Watch App.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/JetpackWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 26.5; + }; + name = "Release-Alpha"; + }; + 942520922FB33FC00027445A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.JetpackWatch-Watch-AppUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = "JetpackWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 26.5; + }; + name = Debug; + }; + 942520932FB33FC00027445A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.JetpackWatch-Watch-AppUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = "JetpackWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 26.5; + }; + name = Release; + }; + 942520942FB33FC00027445A /* Release-Alpha */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.JetpackWatch-Watch-AppUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = "JetpackWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 26.5; + }; + name = "Release-Alpha"; + }; C01FCF4F08A954540054247B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -7247,6 +7912,36 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 942520952FB33FC00027445A /* Build configuration list for PBXNativeTarget "JetpackWatch Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9425208C2FB33FC00027445A /* Debug */, + 9425208D2FB33FC00027445A /* Release */, + 9425208E2FB33FC00027445A /* Release-Alpha */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 942520962FB33FC00027445A /* Build configuration list for PBXNativeTarget "JetpackWatch Watch AppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9425208F2FB33FC00027445A /* Debug */, + 942520902FB33FC00027445A /* Release */, + 942520912FB33FC00027445A /* Release-Alpha */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 942520972FB33FC00027445A /* Build configuration list for PBXNativeTarget "JetpackWatch Watch AppUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 942520922FB33FC00027445A /* Debug */, + 942520932FB33FC00027445A /* Release */, + 942520942FB33FC00027445A /* Release-Alpha */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; C01FCF4E08A954540054247B /* Build configuration list for PBXProject "WordPress" */ = { isa = XCConfigurationList; buildConfigurations = ( From 34d785ffdea3a7dcca6e4f37ff00f8acc557413d Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Tue, 12 May 2026 16:14:13 +0200 Subject: [PATCH 02/23] Scaffold JetpackWatch Watch App with RootView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Xcode-template ContentView with a minimal SwiftUI scaffold. JetpackWatchApp now hosts RootView inside a NavigationStack. No behavior yet — feature work lands in subsequent commits. --- .../JetpackWatch Watch App/App/RootView.swift | 25 +++++++++++++++++++ .../JetpackWatch Watch App/ContentView.swift | 25 ------------------- .../JetpackWatchApp.swift | 12 ++------- 3 files changed, 27 insertions(+), 35 deletions(-) create mode 100644 WordPress/JetpackWatch Watch App/App/RootView.swift delete mode 100644 WordPress/JetpackWatch Watch App/ContentView.swift diff --git a/WordPress/JetpackWatch Watch App/App/RootView.swift b/WordPress/JetpackWatch Watch App/App/RootView.swift new file mode 100644 index 000000000000..aa8de00b9198 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/App/RootView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct RootView: View { + var body: some View { + NavigationStack { + VStack(spacing: 8) { + Image(systemName: "mic.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 48) + .foregroundStyle(.red) + Text("Voice Notes") + .font(.headline) + Text("Scaffolding") + .font(.caption2) + .foregroundStyle(.secondary) + } + .navigationTitle("Jetpack") + } + } +} + +#Preview { + RootView() +} diff --git a/WordPress/JetpackWatch Watch App/ContentView.swift b/WordPress/JetpackWatch Watch App/ContentView.swift deleted file mode 100644 index b264e807115b..000000000000 --- a/WordPress/JetpackWatch Watch App/ContentView.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// ContentView.swift -// JetpackWatch Watch App -// -// Created by Ricardo Ortiz on 12/05/2026. -// Copyright © 2026 WordPress. All rights reserved. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/WordPress/JetpackWatch Watch App/JetpackWatchApp.swift b/WordPress/JetpackWatch Watch App/JetpackWatchApp.swift index 28ab0f65c837..9d43f633900a 100644 --- a/WordPress/JetpackWatch Watch App/JetpackWatchApp.swift +++ b/WordPress/JetpackWatch Watch App/JetpackWatchApp.swift @@ -1,18 +1,10 @@ -// -// JetpackWatchApp.swift -// JetpackWatch Watch App -// -// Created by Ricardo Ortiz on 12/05/2026. -// Copyright © 2026 WordPress. All rights reserved. -// - import SwiftUI @main -struct JetpackWatch_Watch_AppApp: App { +struct JetpackWatchApp: App { var body: some Scene { WindowGroup { - ContentView() + RootView() } } } From 984be0a21d91de64009db31b9be9fae599cf3052 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Tue, 12 May 2026 16:41:02 +0200 Subject: [PATCH 03/23] Add VoiceNote model and NoteStatus enum Codable model used by the Watch-side store. NoteStatus encodes the full state machine; isTerminal/isActive helpers feed UI state. --- .../Domain/VoiceNote.swift | 34 +++++++++++++++ .../VoiceNoteTests.swift | 41 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 WordPress/JetpackWatch Watch App/Domain/VoiceNote.swift create mode 100644 WordPress/JetpackWatch Watch AppTests/VoiceNoteTests.swift diff --git a/WordPress/JetpackWatch Watch App/Domain/VoiceNote.swift b/WordPress/JetpackWatch Watch App/Domain/VoiceNote.swift new file mode 100644 index 000000000000..da3bb72c906c --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Domain/VoiceNote.swift @@ -0,0 +1,34 @@ +import Foundation + +/// A voice note as known to the Watch. The Watch is authoritative for +/// `.recording` and `.queued`; the phone is authoritative for `.uploading` +/// and later (see the design spec for the full state machine). +nonisolated struct VoiceNote: Codable, Equatable, Identifiable, Hashable, Sendable { + let id: UUID + let createdAt: Date + let siteID: Int64 + let audioFilename: String + let durationSeconds: Int + var status: NoteStatus + var statusReason: String? + var postID: Int64? +} + +nonisolated enum NoteStatus: String, Codable, CaseIterable, Hashable, Sendable { + case recording + case queued + case uploading + case transcribing + case drafting + case draftReady = "draft_ready" + case failed + + var isTerminal: Bool { + switch self { + case .draftReady, .failed: return true + case .recording, .queued, .uploading, .transcribing, .drafting: return false + } + } + + var isActive: Bool { !isTerminal } +} diff --git a/WordPress/JetpackWatch Watch AppTests/VoiceNoteTests.swift b/WordPress/JetpackWatch Watch AppTests/VoiceNoteTests.swift new file mode 100644 index 000000000000..01b95a9e7150 --- /dev/null +++ b/WordPress/JetpackWatch Watch AppTests/VoiceNoteTests.swift @@ -0,0 +1,41 @@ +import Testing +import Foundation +@testable import JetpackWatch_Watch_App + +@Suite("VoiceNote") +struct VoiceNoteTests { + + @Test func codable_roundtrip_preserves_all_fields() throws { + let original = VoiceNote( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + createdAt: Date(timeIntervalSince1970: 1_715_500_000), + siteID: 42, + audioFilename: "voice-1.m4a", + durationSeconds: 75, + status: .uploading, + statusReason: nil, + postID: nil + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(VoiceNote.self, from: data) + + #expect(decoded == original) + } + + @Test func isTerminal_returns_true_only_for_draftReady_and_failed() { + #expect(NoteStatus.recording.isTerminal == false) + #expect(NoteStatus.queued.isTerminal == false) + #expect(NoteStatus.uploading.isTerminal == false) + #expect(NoteStatus.transcribing.isTerminal == false) + #expect(NoteStatus.drafting.isTerminal == false) + #expect(NoteStatus.draftReady.isTerminal == true) + #expect(NoteStatus.failed.isTerminal == true) + } + + @Test func isActive_is_complement_of_isTerminal() { + for status in NoteStatus.allCases { + #expect(status.isActive == !status.isTerminal) + } + } +} From 37d9fa896b4f64813453012031d05a3c7301a699 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Tue, 12 May 2026 16:49:37 +0200 Subject: [PATCH 04/23] Add NoteStore for Watch-side voice note persistence JSON-backed @MainActor ObservableObject. 20-note cap with eviction of oldest terminal notes (active notes are never evicted). --- .../Storage/NoteStore.swift | 77 +++++++++++ .../NoteStoreTests.swift | 122 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 WordPress/JetpackWatch Watch App/Storage/NoteStore.swift create mode 100644 WordPress/JetpackWatch Watch AppTests/NoteStoreTests.swift diff --git a/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift b/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift new file mode 100644 index 000000000000..f5779ecaa5aa --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift @@ -0,0 +1,77 @@ +import Foundation +import Combine + +enum NoteStoreError: Error, Equatable, Sendable { + case notFound +} + +/// On-disk persistence for the Watch's voice notes. Single JSON file, rewritten +/// on every mutation. Eviction: when over `cap`, oldest terminal notes go first; +/// active notes are never auto-evicted. +@MainActor +final class NoteStore: ObservableObject { + static let cap = 20 + + @Published private(set) var notes: [VoiceNote] = [] + + private let fileURL: URL + + init(rootURL: URL) { + self.fileURL = rootURL.appendingPathComponent("notes.json") + load() + } + + func add(_ note: VoiceNote) throws { + notes.append(note) + sortNewestFirst() + evictIfNeeded() + try save() + } + + func update(_ note: VoiceNote) throws { + guard let idx = notes.firstIndex(where: { $0.id == note.id }) else { + throw NoteStoreError.notFound + } + notes[idx] = note + sortNewestFirst() + try save() + } + + func delete(id: UUID) throws { + notes.removeAll { $0.id == id } + try save() + } + + private func sortNewestFirst() { + notes.sort { $0.createdAt > $1.createdAt } + } + + private func evictIfNeeded() { + guard notes.count > Self.cap else { return } + let overage = notes.count - Self.cap + let terminalSortedOldestFirst = notes.enumerated() + .filter { $0.element.status.isTerminal } + .sorted { $0.element.createdAt < $1.element.createdAt } + var indicesToRemove = Set() + for (idx, _) in terminalSortedOldestFirst.prefix(overage) { + indicesToRemove.insert(idx) + } + notes = notes.enumerated() + .filter { !indicesToRemove.contains($0.offset) } + .map(\.element) + } + + private func load() { + guard let data = try? Data(contentsOf: fileURL), + let decoded = try? JSONDecoder().decode([VoiceNote].self, from: data) else { + return + } + notes = decoded + sortNewestFirst() + } + + private func save() throws { + let data = try JSONEncoder().encode(notes) + try data.write(to: fileURL, options: .atomic) + } +} diff --git a/WordPress/JetpackWatch Watch AppTests/NoteStoreTests.swift b/WordPress/JetpackWatch Watch AppTests/NoteStoreTests.swift new file mode 100644 index 000000000000..1e0e6ab70010 --- /dev/null +++ b/WordPress/JetpackWatch Watch AppTests/NoteStoreTests.swift @@ -0,0 +1,122 @@ +import Testing +import Foundation +@testable import JetpackWatch_Watch_App + +@Suite("NoteStore") +@MainActor +struct NoteStoreTests { + + private func makeStore() -> (store: NoteStore, tempDir: URL) { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("NoteStoreTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + return (NoteStore(rootURL: tempDir), tempDir) + } + + private func makeNote( + id: UUID = UUID(), + status: NoteStatus = .queued, + siteID: Int64 = 1, + createdAt: Date = Date() + ) -> VoiceNote { + VoiceNote( + id: id, + createdAt: createdAt, + siteID: siteID, + audioFilename: "\(id.uuidString).m4a", + durationSeconds: 30, + status: status, + statusReason: nil, + postID: nil + ) + } + + @Test func empty_store_returns_no_notes() { + let (store, _) = makeStore() + #expect(store.notes.isEmpty) + } + + @Test func add_persists_a_note() throws { + let (store, tempDir) = makeStore() + let note = makeNote() + + try store.add(note) + + #expect(store.notes.count == 1) + #expect(store.notes.first?.id == note.id) + + let reloaded = NoteStore(rootURL: tempDir) + #expect(reloaded.notes.count == 1) + #expect(reloaded.notes.first?.id == note.id) + } + + @Test func update_replaces_existing_note_by_id() throws { + let (store, _) = makeStore() + var note = makeNote(status: .queued) + try store.add(note) + + note.status = .uploading + try store.update(note) + + #expect(store.notes.first?.status == .uploading) + } + + @Test func update_throws_when_note_unknown() { + let (store, _) = makeStore() + let note = makeNote() + + #expect(throws: NoteStoreError.notFound) { + try store.update(note) + } + } + + @Test func delete_removes_a_note() throws { + let (store, _) = makeStore() + let note = makeNote() + try store.add(note) + + try store.delete(id: note.id) + + #expect(store.notes.isEmpty) + } + + @Test func notes_are_sorted_newest_first() throws { + let (store, _) = makeStore() + let old = makeNote(createdAt: Date(timeIntervalSince1970: 1_000_000)) + let new = makeNote(createdAt: Date(timeIntervalSince1970: 2_000_000)) + try store.add(old) + try store.add(new) + + #expect(store.notes.first?.id == new.id) + } + + @Test func eviction_removes_oldest_terminal_notes_over_cap() throws { + let (store, _) = makeStore() + + for i in 0..<18 { + try store.add(makeNote( + status: .draftReady, + createdAt: Date(timeIntervalSince1970: TimeInterval(i)) + )) + } + try store.add(makeNote(status: .uploading)) + try store.add(makeNote(status: .transcribing)) + try store.add(makeNote(status: .drafting)) + + // 21 total, cap is 20 → one oldest draftReady evicted + #expect(store.notes.count == 20) + let kept = store.notes.map(\.createdAt.timeIntervalSince1970) + #expect(kept.contains(0) == false) + } + + @Test func eviction_never_removes_active_notes() throws { + let (store, _) = makeStore() + for i in 0..<25 { + try store.add(makeNote( + status: .uploading, + createdAt: Date(timeIntervalSince1970: TimeInterval(i)) + )) + } + #expect(store.notes.count == 25) + } +} From 5da221d00fc89423c8a18938050cdd948fb8c2f7 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Tue, 12 May 2026 16:54:10 +0200 Subject: [PATCH 05/23] Add AudioRecorder for Watch voice capture AVAudioRecorder wrapper producing AAC/m4a at 32 kbps mono / 16 kHz with a 5-minute hard cap. cancel() cleans up partial files. Class is non-final to allow test subclassing in later tasks. --- .../Audio/AudioRecorder.swift | 107 ++++++++++++++++++ .../AudioRecorderTests.swift | 48 ++++++++ 2 files changed, 155 insertions(+) create mode 100644 WordPress/JetpackWatch Watch App/Audio/AudioRecorder.swift create mode 100644 WordPress/JetpackWatch Watch AppTests/AudioRecorderTests.swift diff --git a/WordPress/JetpackWatch Watch App/Audio/AudioRecorder.swift b/WordPress/JetpackWatch Watch App/Audio/AudioRecorder.swift new file mode 100644 index 000000000000..5fb4f89c7751 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Audio/AudioRecorder.swift @@ -0,0 +1,107 @@ +import Foundation +import AVFoundation +import Combine + +enum AudioRecorderError: Error, Sendable { + case failedToStart +} + +/// Wraps AVAudioRecorder for the Watch. Produces AAC/m4a at 32 kbps mono / +/// 16 kHz. Hard cap: 5 minutes (300 s). Subclass-friendly for testing. +@MainActor +class AudioRecorder: NSObject, ObservableObject { + static let maximumDuration: TimeInterval = 300 + + static let recordSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVNumberOfChannelsKey: 1, + AVSampleRateKey: 16_000.0, + AVEncoderBitRateKey: 32_000, + AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue, + ] + + @Published private(set) var isRecording: Bool = false + @Published private(set) var currentDuration: TimeInterval = 0 + + private let rootURL: URL + private var recorder: AVAudioRecorder? + private var currentID: UUID? + private var timer: Timer? + private var onAutoStop: (() -> Void)? + + init(rootURL: URL) { + self.rootURL = rootURL + super.init() + } + + func fileURL(for id: UUID) -> URL { + rootURL.appendingPathComponent("\(id.uuidString).m4a") + } + + /// Throws if mic permission is denied or AVAudioRecorder can't start. + /// Auto-stops at `maximumDuration`; `onAutoStop` runs on the main actor. + func start(id: UUID, onAutoStop: @escaping () -> Void) throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .default, options: []) + try session.setActive(true) + + let url = fileURL(for: id) + let r = try AVAudioRecorder(url: url, settings: Self.recordSettings) + r.delegate = self + guard r.record(forDuration: Self.maximumDuration) else { + throw AudioRecorderError.failedToStart + } + self.recorder = r + self.currentID = id + self.onAutoStop = onAutoStop + self.isRecording = true + self.currentDuration = 0 + + timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, let recorder = self.recorder else { return } + self.currentDuration = recorder.currentTime + } + } + } + + /// Stops the current recording. Returns the final URL or nil. + @discardableResult + func stop() -> URL? { + guard let recorder, let id = currentID else { return nil } + recorder.stop() + let url = fileURL(for: id) + cleanup() + return url + } + + /// Aborts recording for `id` and deletes any partial file on disk. + func cancel(id: UUID) { + if currentID == id { + recorder?.stop() + cleanup() + } + let url = fileURL(for: id) + try? FileManager.default.removeItem(at: url) + } + + private func cleanup() { + timer?.invalidate() + timer = nil + recorder = nil + currentID = nil + onAutoStop = nil + isRecording = false + currentDuration = 0 + } +} + +extension AudioRecorder: AVAudioRecorderDelegate { + nonisolated func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { + Task { @MainActor in + let callback = self.onAutoStop + self.cleanup() + callback?() + } + } +} diff --git a/WordPress/JetpackWatch Watch AppTests/AudioRecorderTests.swift b/WordPress/JetpackWatch Watch AppTests/AudioRecorderTests.swift new file mode 100644 index 000000000000..81f68120d8a1 --- /dev/null +++ b/WordPress/JetpackWatch Watch AppTests/AudioRecorderTests.swift @@ -0,0 +1,48 @@ +import Testing +import Foundation +import AVFoundation +@testable import JetpackWatch_Watch_App + +@Suite("AudioRecorder") +@MainActor +struct AudioRecorderTests { + + private func makeRecorder() -> (recorder: AudioRecorder, tempDir: URL) { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("AudioRecorderTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + return (AudioRecorder(rootURL: tempDir), tempDir) + } + + @Test func fileURL_for_id_is_inside_root() { + let (recorder, tempDir) = makeRecorder() + let id = UUID() + let url = recorder.fileURL(for: id) + #expect(url.path.hasPrefix(tempDir.path)) + #expect(url.lastPathComponent == "\(id.uuidString).m4a") + } + + @Test func cancel_removes_partial_file() throws { + let (recorder, _) = makeRecorder() + let id = UUID() + let url = recorder.fileURL(for: id) + + try Data([0x00, 0x01]).write(to: url) + #expect(FileManager.default.fileExists(atPath: url.path)) + + recorder.cancel(id: id) + #expect(FileManager.default.fileExists(atPath: url.path) == false) + } + + @Test func maximumDuration_is_300_seconds() { + #expect(AudioRecorder.maximumDuration == 300) + } + + @Test func recording_settings_are_aac_mono_32kbps_16khz() { + let settings = AudioRecorder.recordSettings + #expect(settings[AVFormatIDKey] as? UInt32 == kAudioFormatMPEG4AAC) + #expect(settings[AVNumberOfChannelsKey] as? Int == 1) + #expect(settings[AVSampleRateKey] as? Double == 16_000) + #expect(settings[AVEncoderBitRateKey] as? Int == 32_000) + } +} From 7e8b5d3cbe88514a9bf442842adef627e82d59ae Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Tue, 12 May 2026 18:05:14 +0200 Subject: [PATCH 06/23] Add Site model and SiteCatalog Caches site list and default-site selection on disk. Hydrated from the phone via PhoneBridge in Plan 2; seeded by MockPhoneBridge in Plan 1. --- .../JetpackWatch Watch App/Domain/Site.swift | 16 ++++++ .../Storage/SiteCatalog.swift | 50 +++++++++++++++++ .../SiteCatalogTests.swift | 53 +++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 WordPress/JetpackWatch Watch App/Domain/Site.swift create mode 100644 WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift create mode 100644 WordPress/JetpackWatch Watch AppTests/SiteCatalogTests.swift diff --git a/WordPress/JetpackWatch Watch App/Domain/Site.swift b/WordPress/JetpackWatch Watch App/Domain/Site.swift new file mode 100644 index 000000000000..4bc35f056031 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Domain/Site.swift @@ -0,0 +1,16 @@ +import Foundation + +nonisolated struct Site: Codable, Equatable, Identifiable, Hashable, Sendable { + let id: Int64 + let name: String +} + +#if DEBUG +extension Site { + static let previewSeed: [Site] = [ + Site(id: 1, name: "My Personal Blog"), + Site(id: 2, name: "Travel Notes"), + Site(id: 3, name: "Cooking Adventures"), + ] +} +#endif diff --git a/WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift b/WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift new file mode 100644 index 000000000000..4c3cda07a646 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift @@ -0,0 +1,50 @@ +import Foundation +import Combine + +/// Caches the list of sites and the user's selected default site. +/// JSON-backed: two files inside the root directory. +@MainActor +final class SiteCatalog: ObservableObject { + @Published private(set) var sites: [Site] = [] + @Published private(set) var defaultSiteID: Int64? + + var defaultSite: Site? { + guard let id = defaultSiteID else { return nil } + return sites.first(where: { $0.id == id }) + } + + private let sitesURL: URL + private let defaultURL: URL + + init(rootURL: URL) { + self.sitesURL = rootURL.appendingPathComponent("sites.json") + self.defaultURL = rootURL.appendingPathComponent("default-site.json") + load() + } + + func setSites(_ sites: [Site]) { + self.sites = sites + try? save(sites, to: sitesURL) + } + + func setDefaultSiteID(_ id: Int64?) { + self.defaultSiteID = id + try? save(id, to: defaultURL) + } + + private func load() { + if let data = try? Data(contentsOf: sitesURL), + let decoded = try? JSONDecoder().decode([Site].self, from: data) { + sites = decoded + } + if let data = try? Data(contentsOf: defaultURL), + let decoded = try? JSONDecoder().decode(Int64?.self, from: data) { + defaultSiteID = decoded + } + } + + private func save(_ value: T, to url: URL) throws { + let data = try JSONEncoder().encode(value) + try data.write(to: url, options: .atomic) + } +} diff --git a/WordPress/JetpackWatch Watch AppTests/SiteCatalogTests.swift b/WordPress/JetpackWatch Watch AppTests/SiteCatalogTests.swift new file mode 100644 index 000000000000..6017a0ff6d68 --- /dev/null +++ b/WordPress/JetpackWatch Watch AppTests/SiteCatalogTests.swift @@ -0,0 +1,53 @@ +import Testing +import Foundation +@testable import JetpackWatch_Watch_App + +@Suite("SiteCatalog") +@MainActor +struct SiteCatalogTests { + + private func makeCatalog() -> (catalog: SiteCatalog, tempDir: URL) { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("SiteCatalogTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + return (SiteCatalog(rootURL: tempDir), tempDir) + } + + @Test func empty_catalog_has_no_sites_and_no_default() { + let (catalog, _) = makeCatalog() + #expect(catalog.sites.isEmpty) + #expect(catalog.defaultSiteID == nil) + } + + @Test func setSites_persists_and_reloads() { + let (catalog, tempDir) = makeCatalog() + catalog.setSites([Site(id: 1, name: "Alpha"), Site(id: 2, name: "Beta")]) + + let reloaded = SiteCatalog(rootURL: tempDir) + #expect(reloaded.sites.count == 2) + #expect(reloaded.sites.contains(where: { $0.id == 1 })) + } + + @Test func setDefaultSiteID_persists_and_reloads() { + let (catalog, tempDir) = makeCatalog() + catalog.setSites([Site(id: 1, name: "Alpha")]) + catalog.setDefaultSiteID(1) + + let reloaded = SiteCatalog(rootURL: tempDir) + #expect(reloaded.defaultSiteID == 1) + } + + @Test func defaultSite_returns_matching_Site() { + let (catalog, _) = makeCatalog() + catalog.setSites([Site(id: 1, name: "Alpha"), Site(id: 2, name: "Beta")]) + catalog.setDefaultSiteID(2) + #expect(catalog.defaultSite?.name == "Beta") + } + + @Test func defaultSite_is_nil_when_default_id_no_longer_in_sites() { + let (catalog, _) = makeCatalog() + catalog.setSites([Site(id: 1, name: "Alpha")]) + catalog.setDefaultSiteID(99) + #expect(catalog.defaultSite == nil) + } +} From 9910aa95b075bc0c56cea1a73934f180667c8a06 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Tue, 12 May 2026 18:13:42 +0200 Subject: [PATCH 07/23] Add PhoneBridge protocol and MockPhoneBridge Defines the Watch-to-phone seam that Plan 2 will replace with a WCSession-backed implementation. The mock returns seed data and records all writes for assertions. --- .../Domain/PhoneBridge.swift | 66 +++++++++++++++++++ .../PhoneBridgeMockTests.swift | 56 ++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 WordPress/JetpackWatch Watch App/Domain/PhoneBridge.swift create mode 100644 WordPress/JetpackWatch Watch AppTests/PhoneBridgeMockTests.swift diff --git a/WordPress/JetpackWatch Watch App/Domain/PhoneBridge.swift b/WordPress/JetpackWatch Watch App/Domain/PhoneBridge.swift new file mode 100644 index 000000000000..a15949696081 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Domain/PhoneBridge.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Abstracts the Watch's view of the paired iPhone. +/// Plan 1: implemented by `MockPhoneBridge` (returns canned data). +/// Plan 2: implemented by a `WCSession`-backed type. +@MainActor +protocol PhoneBridge: AnyObject { + /// Begin observing connectivity / receiving updates from the phone. + func start() async + + /// Send the audio file for a queued note to the phone. + func handOff(noteID: UUID, audioURL: URL, siteID: Int64) async + + /// Ask the phone to retry a previously failed note. + func retry(noteID: UUID) async + + /// Tell the phone the user picked a new default site on the Watch. + func setDefaultSiteID(_ id: Int64) async + + /// Ask the phone to delete a note end-to-end. + func deleteNote(_ id: UUID) async + + /// Called when the phone pushes a fresh site list. + var onSitesReceived: (([Site]) -> Void)? { get set } + + /// Called when the phone pushes a state update for a note. + /// Parameters: noteID, status, postID (if draft_ready), statusReason (if failed). + var onNoteStateUpdate: ((UUID, NoteStatus, Int64?, String?) -> Void)? { get set } +} + +@MainActor +final class MockPhoneBridge: PhoneBridge { + var onSitesReceived: (([Site]) -> Void)? + var onNoteStateUpdate: ((UUID, NoteStatus, Int64?, String?) -> Void)? + + private(set) var handedOffNoteIDs: [UUID] = [] + private(set) var retriedNoteIDs: [UUID] = [] + private(set) var defaultSiteIDsSet: [Int64] = [] + private(set) var deletedNoteIDs: [UUID] = [] + + private let seedSites: [Site] + + init(seedSites: [Site]) { + self.seedSites = seedSites + } + + func start() async { + onSitesReceived?(seedSites) + } + + func handOff(noteID: UUID, audioURL: URL, siteID: Int64) async { + handedOffNoteIDs.append(noteID) + } + + func retry(noteID: UUID) async { + retriedNoteIDs.append(noteID) + } + + func setDefaultSiteID(_ id: Int64) async { + defaultSiteIDsSet.append(id) + } + + func deleteNote(_ id: UUID) async { + deletedNoteIDs.append(id) + } +} diff --git a/WordPress/JetpackWatch Watch AppTests/PhoneBridgeMockTests.swift b/WordPress/JetpackWatch Watch AppTests/PhoneBridgeMockTests.swift new file mode 100644 index 000000000000..e52430713991 --- /dev/null +++ b/WordPress/JetpackWatch Watch AppTests/PhoneBridgeMockTests.swift @@ -0,0 +1,56 @@ +import Testing +import Foundation +@testable import JetpackWatch_Watch_App + +@Suite("MockPhoneBridge") +@MainActor +struct PhoneBridgeMockTests { + + @Test func start_with_seed_sites_publishes_them_via_callback() async { + let sites: [Site] = [Site(id: 1, name: "Alpha")] + let bridge = MockPhoneBridge(seedSites: sites) + + var received: [Site]? + bridge.onSitesReceived = { received = $0 } + await bridge.start() + + #expect(received?.count == 1) + #expect(received?.first?.id == 1) + } + + @Test func handOff_records_the_note_id() async { + let bridge = MockPhoneBridge(seedSites: []) + let id = UUID() + let url = URL(fileURLWithPath: "/tmp/x.m4a") + + await bridge.handOff(noteID: id, audioURL: url, siteID: 1) + + #expect(bridge.handedOffNoteIDs.contains(id)) + } + + @Test func retry_records_the_note_id() async { + let bridge = MockPhoneBridge(seedSites: []) + let id = UUID() + + await bridge.retry(noteID: id) + + #expect(bridge.retriedNoteIDs.contains(id)) + } + + @Test func setDefaultSiteID_records_the_value() async { + let bridge = MockPhoneBridge(seedSites: []) + + await bridge.setDefaultSiteID(42) + + #expect(bridge.defaultSiteIDsSet == [42]) + } + + @Test func deleteNote_records_the_note_id() async { + let bridge = MockPhoneBridge(seedSites: []) + let id = UUID() + + await bridge.deleteNote(id) + + #expect(bridge.deletedNoteIDs.contains(id)) + } +} From 52bf659fb1b7822c7e7be024373e9f247b6f56f7 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Tue, 12 May 2026 19:24:59 +0200 Subject: [PATCH 08/23] Add HandoffPublisher for draft-ready handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishes an NSUserActivity tagged with postID and siteID. Plan 3 will add a matching userActivity handler in Jetpack iOS that opens the draft editor. Declares the activity type via a real Info.plist on the Watch target (GENERATE_INFOPLIST_FILE replaced by INFOPLIST_FILE) so NSUserActivityTypes — an array key — is expressed correctly. --- .../Handoff/HandoffPublisher.swift | 23 +++++++++++ WordPress/JetpackWatch Watch App/Info.plist | 39 +++++++++++++++++++ .../HandoffPublisherTests.swift | 25 ++++++++++++ WordPress/WordPress.xcodeproj/project.pbxproj | 28 +++++++------ 4 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 WordPress/JetpackWatch Watch App/Handoff/HandoffPublisher.swift create mode 100644 WordPress/JetpackWatch Watch App/Info.plist create mode 100644 WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift diff --git a/WordPress/JetpackWatch Watch App/Handoff/HandoffPublisher.swift b/WordPress/JetpackWatch Watch App/Handoff/HandoffPublisher.swift new file mode 100644 index 000000000000..5a923e2d312e --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Handoff/HandoffPublisher.swift @@ -0,0 +1,23 @@ +import Foundation + +@MainActor +final class HandoffPublisher { + static let activityType = "com.automattic.jetpack.voice-note-draft" + + private(set) var currentActivity: NSUserActivity? + + func publishDraftReady(postID: Int64, siteID: Int64) { + let activity = NSUserActivity(activityType: Self.activityType) + activity.title = "Open voice-note draft" + activity.userInfo = ["postID": postID, "siteID": siteID] + activity.isEligibleForHandoff = true + activity.isEligibleForPublicIndexing = false + activity.becomeCurrent() + currentActivity = activity + } + + func clear() { + currentActivity?.invalidate() + currentActivity = nil + } +} diff --git a/WordPress/JetpackWatch Watch App/Info.plist b/WordPress/JetpackWatch Watch App/Info.plist new file mode 100644 index 000000000000..bbb851fd111e --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + JetpackWatch + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSMicrophoneUsageDescription + Jetpack records your voice notes and turns them into draft blog posts. + NSUserActivityTypes + + com.automattic.jetpack.voice-note-draft + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + WKApplication + + WKCompanionAppBundleIdentifier + com.automattic.jetpack + + diff --git a/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift b/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift new file mode 100644 index 000000000000..d55bf0b86206 --- /dev/null +++ b/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift @@ -0,0 +1,25 @@ +import Testing +import Foundation +@testable import JetpackWatch_Watch_App + +@Suite("HandoffPublisher") +@MainActor +struct HandoffPublisherTests { + + @Test func publishDraftReady_sets_activity_with_post_and_site_ids() { + let publisher = HandoffPublisher() + publisher.publishDraftReady(postID: 789, siteID: 42) + + let activity = publisher.currentActivity + #expect(activity?.activityType == HandoffPublisher.activityType) + #expect(activity?.userInfo?["postID"] as? Int64 == 789) + #expect(activity?.userInfo?["siteID"] as? Int64 == 42) + } + + @Test func clear_invalidates_the_activity() { + let publisher = HandoffPublisher() + publisher.publishDraftReady(postID: 1, siteID: 1) + publisher.clear() + #expect(publisher.currentActivity == nil) + } +} diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index fee89f252480..ca17c82d62c7 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -1150,6 +1150,13 @@ ); target = 932225A61C7CE50300443B02 /* WordPressShareExtension */; }; + D10AEBD1535F4CC3B0EE9E3E /* Exceptions for "JetpackWatch Watch App" folder in "JetpackWatch Watch App" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 942520692FB33FBE0027445A /* JetpackWatch Watch App */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ @@ -1288,6 +1295,9 @@ }; 9425206B2FB33FBE0027445A /* JetpackWatch Watch App */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + D10AEBD1535F4CC3B0EE9E3E /* Exceptions for "JetpackWatch Watch App" folder in "JetpackWatch Watch App" target */, + ); path = "JetpackWatch Watch App"; sourceTree = ""; }; @@ -6793,11 +6803,7 @@ ); GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = JetpackWatch; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "Jetpack records your voice notes and turns them into draft blog posts."; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.automattic.jetpack; + INFOPLIST_FILE = "JetpackWatch Watch App/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6852,11 +6858,7 @@ GCC_C_LANGUAGE_STANDARD = gnu17; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = JetpackWatch; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "Jetpack records your voice notes and turns them into draft blog posts."; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.automattic.jetpack; + INFOPLIST_FILE = "JetpackWatch Watch App/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6910,11 +6912,7 @@ GCC_C_LANGUAGE_STANDARD = gnu17; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = JetpackWatch; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "Jetpack records your voice notes and turns them into draft blog posts."; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.automattic.jetpack; + INFOPLIST_FILE = "JetpackWatch Watch App/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", From ea3140b6dd386bd7a4ebe870a1d4c4ec8e8a4e0c Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Tue, 12 May 2026 21:30:57 +0200 Subject: [PATCH 09/23] Add RecordingViewModel for record/stop coordination Owns the record button state machine. Creates a queued VoiceNote on stop and hands off to PhoneBridge. Auto-stop handler triggered by the 5-min cap goes through the same finalize path. --- .../ViewModels/RecordingViewModel.swift | 81 +++++++++++++ .../RecordingViewModelTests.swift | 111 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift create mode 100644 WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift diff --git a/WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift b/WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift new file mode 100644 index 000000000000..62f70524e1a2 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift @@ -0,0 +1,81 @@ +import Foundation +import Combine + +enum RecordingViewModelError: Error, Equatable, Sendable { + case noDefaultSite + case alreadyRecording +} + +enum RecordingState: Equatable, Sendable { + case idle + case recording(noteID: UUID, startedAt: Date) +} + +@MainActor +final class RecordingViewModel: ObservableObject { + @Published private(set) var state: RecordingState = .idle + + private let recorder: AudioRecorder + private let store: NoteStore + private let siteCatalog: SiteCatalog + private let phoneBridge: any PhoneBridge + + init( + recorder: AudioRecorder, + store: NoteStore, + siteCatalog: SiteCatalog, + phoneBridge: any PhoneBridge + ) { + self.recorder = recorder + self.store = store + self.siteCatalog = siteCatalog + self.phoneBridge = phoneBridge + } + + func startRecording() throws { + if case .recording = state { throw RecordingViewModelError.alreadyRecording } + guard siteCatalog.defaultSiteID != nil else { + throw RecordingViewModelError.noDefaultSite + } + + let id = UUID() + let startedAt = Date() + try recorder.start(id: id) { [weak self] in + Task { @MainActor [weak self] in + try? self?.finalize() + } + } + state = .recording(noteID: id, startedAt: startedAt) + } + + func stopRecording() throws { + if case .recording = state { + _ = recorder.stop() + try finalize() + } + } + + private func finalize() throws { + guard case let .recording(id, startedAt) = state else { return } + guard let siteID = siteCatalog.defaultSiteID else { + state = .idle + return + } + let duration = Int(Date().timeIntervalSince(startedAt)) + let note = VoiceNote( + id: id, + createdAt: startedAt, + siteID: siteID, + audioFilename: "\(id.uuidString).m4a", + durationSeconds: duration, + status: .queued, + statusReason: nil, + postID: nil + ) + try store.add(note) + let audioURL = recorder.fileURL(for: id) + let bridge = phoneBridge + Task { await bridge.handOff(noteID: id, audioURL: audioURL, siteID: siteID) } + state = .idle + } +} diff --git a/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift b/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift new file mode 100644 index 000000000000..224664013d97 --- /dev/null +++ b/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift @@ -0,0 +1,111 @@ +import Testing +import Foundation +@testable import JetpackWatch_Watch_App + +@Suite("RecordingViewModel") +@MainActor +struct RecordingViewModelTests { + + private func makeViewModel(siteID: Int64? = 1) -> ( + vm: RecordingViewModel, + recorder: StubAudioRecorder, + store: NoteStore, + bridge: MockPhoneBridge + ) { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("RecordingVMTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let recorder = StubAudioRecorder(rootURL: tempDir) + let store = NoteStore(rootURL: tempDir) + let bridge = MockPhoneBridge(seedSites: []) + let catalog = SiteCatalog(rootURL: tempDir) + if let siteID { + catalog.setSites([Site(id: siteID, name: "Test Site")]) + catalog.setDefaultSiteID(siteID) + } + let vm = RecordingViewModel( + recorder: recorder, + store: store, + siteCatalog: catalog, + phoneBridge: bridge + ) + return (vm, recorder, store, bridge) + } + + @Test func initial_state_is_idle() { + let (vm, _, _, _) = makeViewModel() + if case .idle = vm.state {} else { Issue.record("expected .idle"); return } + } + + @Test func startRecording_transitions_state_and_starts_recorder() throws { + let (vm, recorder, _, _) = makeViewModel() + + try vm.startRecording() + + if case .recording = vm.state {} else { Issue.record("expected .recording"); return } + #expect(recorder.startedID != nil) + } + + @Test func stopRecording_persists_note_and_hands_to_phone() async throws { + let (vm, _, store, bridge) = makeViewModel() + try vm.startRecording() + + try vm.stopRecording() + + // Allow the Task spawned in finalize() to flush. + try await Task.sleep(nanoseconds: 50_000_000) + + if case .idle = vm.state {} else { Issue.record("expected .idle"); return } + #expect(store.notes.count == 1) + let note = try #require(store.notes.first) + #expect(note.status == .queued) + #expect(bridge.handedOffNoteIDs.contains(note.id)) + } + + @Test func startRecording_throws_when_no_default_site() { + let (vm, _, _, _) = makeViewModel(siteID: nil) + + #expect(throws: RecordingViewModelError.noDefaultSite) { + try vm.startRecording() + } + } + + @Test func auto_stop_callback_finalizes_the_note() async throws { + let (vm, recorder, store, _) = makeViewModel() + try vm.startRecording() + + recorder.triggerAutoStop() + try await Task.sleep(nanoseconds: 50_000_000) + + if case .idle = vm.state {} else { Issue.record("expected .idle"); return } + #expect(store.notes.count == 1) + #expect(store.notes.first?.status == .queued) + } +} + +@MainActor +final class StubAudioRecorder: AudioRecorder { + var startedID: UUID? + var stopped = false + var cancelled: [UUID] = [] + private var autoStop: (() -> Void)? + + override func start(id: UUID, onAutoStop: @escaping () -> Void) throws { + startedID = id + autoStop = onAutoStop + } + + override func stop() -> URL? { + stopped = true + guard let id = startedID else { return nil } + return fileURL(for: id) + } + + override func cancel(id: UUID) { + cancelled.append(id) + } + + func triggerAutoStop() { + autoStop?() + } +} From 1e3e8eab7d1572e22d3971840a98656d2f4ed6c5 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Tue, 12 May 2026 21:47:00 +0200 Subject: [PATCH 10/23] Wire AppEnvironment composition root Single @MainActor ObservableObject holds noteStore, siteCatalog, phoneBridge, audioRecorder, handoffPublisher. live() seeds against MockPhoneBridge with development sites under #if DEBUG. RootView shows a temporary wiring check; real screens land next. --- .../App/AppEnvironment.swift | 93 +++++++++++++++++++ .../JetpackWatch Watch App/App/RootView.swift | 20 ++-- .../JetpackWatchApp.swift | 3 + 3 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 WordPress/JetpackWatch Watch App/App/AppEnvironment.swift diff --git a/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift b/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift new file mode 100644 index 000000000000..361f820b2246 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift @@ -0,0 +1,93 @@ +import Foundation +import Combine +import SwiftUI + +@MainActor +final class AppEnvironment: ObservableObject { + nonisolated let objectWillChange = ObservableObjectPublisher() + let noteStore: NoteStore + let siteCatalog: SiteCatalog + let phoneBridge: any PhoneBridge + let audioRecorder: AudioRecorder + let handoffPublisher: HandoffPublisher + + init( + noteStore: NoteStore, + siteCatalog: SiteCatalog, + phoneBridge: any PhoneBridge, + audioRecorder: AudioRecorder, + handoffPublisher: HandoffPublisher + ) { + self.noteStore = noteStore + self.siteCatalog = siteCatalog + self.phoneBridge = phoneBridge + self.audioRecorder = audioRecorder + self.handoffPublisher = handoffPublisher + } + + static func live() -> AppEnvironment { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let noteStore = NoteStore(rootURL: docs) + let siteCatalog = SiteCatalog(rootURL: docs) + let seed = AppEnvironment.developmentSeedSites + let bridge = MockPhoneBridge(seedSites: seed) + let audioRecorder = AudioRecorder(rootURL: docs) + let handoff = HandoffPublisher() + + bridge.onSitesReceived = { sites in + siteCatalog.setSites(sites) + if siteCatalog.defaultSiteID == nil, let first = sites.first { + siteCatalog.setDefaultSiteID(first.id) + } + } + bridge.onNoteStateUpdate = { id, status, postID, reason in + guard var note = noteStore.notes.first(where: { $0.id == id }) else { return } + note.status = status + if let postID { note.postID = postID } + note.statusReason = reason + try? noteStore.update(note) + } + Task { await bridge.start() } + + return AppEnvironment( + noteStore: noteStore, + siteCatalog: siteCatalog, + phoneBridge: bridge, + audioRecorder: audioRecorder, + handoffPublisher: handoff + ) + } +} + +private extension AppEnvironment { + /// Seed shown while Plan 1 uses MockPhoneBridge. Plan 2 swaps this for + /// real sites received over WatchConnectivity. + static var developmentSeedSites: [Site] { + #if DEBUG + return Site.previewSeed + #else + return [] + #endif + } +} + +#if DEBUG +extension AppEnvironment { + static func preview() -> AppEnvironment { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("watch-preview-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let noteStore = NoteStore(rootURL: tempDir) + let siteCatalog = SiteCatalog(rootURL: tempDir) + siteCatalog.setSites(Site.previewSeed) + siteCatalog.setDefaultSiteID(Site.previewSeed.first?.id) + return AppEnvironment( + noteStore: noteStore, + siteCatalog: siteCatalog, + phoneBridge: MockPhoneBridge(seedSites: Site.previewSeed), + audioRecorder: AudioRecorder(rootURL: tempDir), + handoffPublisher: HandoffPublisher() + ) + } +} +#endif diff --git a/WordPress/JetpackWatch Watch App/App/RootView.swift b/WordPress/JetpackWatch Watch App/App/RootView.swift index aa8de00b9198..bf2f8bf484b5 100644 --- a/WordPress/JetpackWatch Watch App/App/RootView.swift +++ b/WordPress/JetpackWatch Watch App/App/RootView.swift @@ -1,19 +1,16 @@ import SwiftUI struct RootView: View { + @EnvironmentObject private var env: AppEnvironment + var body: some View { NavigationStack { - VStack(spacing: 8) { - Image(systemName: "mic.circle.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: 48) - .foregroundStyle(.red) - Text("Voice Notes") - .font(.headline) - Text("Scaffolding") - .font(.caption2) - .foregroundStyle(.secondary) + List { + Section("Wiring check") { + LabeledContent("Sites", value: "\(env.siteCatalog.sites.count)") + LabeledContent("Default", value: env.siteCatalog.defaultSite?.name ?? "—") + LabeledContent("Notes", value: "\(env.noteStore.notes.count)") + } } .navigationTitle("Jetpack") } @@ -22,4 +19,5 @@ struct RootView: View { #Preview { RootView() + .environmentObject(AppEnvironment.preview()) } diff --git a/WordPress/JetpackWatch Watch App/JetpackWatchApp.swift b/WordPress/JetpackWatch Watch App/JetpackWatchApp.swift index 9d43f633900a..3be561dcf135 100644 --- a/WordPress/JetpackWatch Watch App/JetpackWatchApp.swift +++ b/WordPress/JetpackWatch Watch App/JetpackWatchApp.swift @@ -2,9 +2,12 @@ import SwiftUI @main struct JetpackWatchApp: App { + @StateObject private var env = AppEnvironment.live() + var body: some Scene { WindowGroup { RootView() + .environmentObject(env) } } } From ae25497929c9fee5a91570a0327f5943569ba86e Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Wed, 13 May 2026 00:29:58 +0200 Subject: [PATCH 11/23] Add RecordView, HistoryView, NoteRowView, SitePickerView Watch-side feature surface for the voice-note MVP: tap record on the root screen, swipe a note to delete or retry, tap a Draft ready row to handoff to Jetpack iOS, switch site via the site picker. --- .../JetpackWatch Watch App/App/RootView.swift | 10 +-- .../Views/HistoryView.swift | 46 ++++++++++ .../Views/NoteRowView.swift | 50 +++++++++++ .../Views/RecordView.swift | 85 +++++++++++++++++++ .../Views/SitePickerView.swift | 38 +++++++++ 5 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 WordPress/JetpackWatch Watch App/Views/HistoryView.swift create mode 100644 WordPress/JetpackWatch Watch App/Views/NoteRowView.swift create mode 100644 WordPress/JetpackWatch Watch App/Views/RecordView.swift create mode 100644 WordPress/JetpackWatch Watch App/Views/SitePickerView.swift diff --git a/WordPress/JetpackWatch Watch App/App/RootView.swift b/WordPress/JetpackWatch Watch App/App/RootView.swift index bf2f8bf484b5..4590816d8cf4 100644 --- a/WordPress/JetpackWatch Watch App/App/RootView.swift +++ b/WordPress/JetpackWatch Watch App/App/RootView.swift @@ -5,14 +5,8 @@ struct RootView: View { var body: some View { NavigationStack { - List { - Section("Wiring check") { - LabeledContent("Sites", value: "\(env.siteCatalog.sites.count)") - LabeledContent("Default", value: env.siteCatalog.defaultSite?.name ?? "—") - LabeledContent("Notes", value: "\(env.noteStore.notes.count)") - } - } - .navigationTitle("Jetpack") + RecordView(env: env) + .navigationTitle("Jetpack") } } } diff --git a/WordPress/JetpackWatch Watch App/Views/HistoryView.swift b/WordPress/JetpackWatch Watch App/Views/HistoryView.swift new file mode 100644 index 000000000000..5bf3693c28e4 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Views/HistoryView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct HistoryView: View { + @EnvironmentObject private var env: AppEnvironment + + var body: some View { + Group { + if env.noteStore.notes.isEmpty { + ContentUnavailableView( + "No voice notes yet", + systemImage: "mic.slash", + description: Text("Tap the record button to start one.") + ) + } else { + List(env.noteStore.notes) { note in + Button { tap(note) } label: { + NoteRowView(note: note) + } + .disabled(note.status != .draftReady) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + try? env.noteStore.delete(id: note.id) + } label: { + Label("Delete", systemImage: "trash") + } + if note.status == .failed { + let bridge = env.phoneBridge + Button { + Task { await bridge.retry(noteID: note.id) } + } label: { + Label("Retry", systemImage: "arrow.clockwise") + } + .tint(.orange) + } + } + } + } + } + .navigationTitle("History") + } + + private func tap(_ note: VoiceNote) { + guard note.status == .draftReady, let postID = note.postID else { return } + env.handoffPublisher.publishDraftReady(postID: postID, siteID: note.siteID) + } +} diff --git a/WordPress/JetpackWatch Watch App/Views/NoteRowView.swift b/WordPress/JetpackWatch Watch App/Views/NoteRowView.swift new file mode 100644 index 000000000000..7ae764b231a5 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Views/NoteRowView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct NoteRowView: View { + let note: VoiceNote + + var body: some View { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(relativeTimeString) + .font(.footnote) + Text(statusText) + .font(.caption2) + .foregroundStyle(statusColor) + } + Spacer() + if note.status == .draftReady { + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Voice note from \(relativeTimeString), \(statusText)") + } + + private var relativeTimeString: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: note.createdAt, relativeTo: Date()) + } + + private var statusText: String { + switch note.status { + case .recording: return "Recording…" + case .queued: return "Queued" + case .uploading: return "Uploading…" + case .transcribing: return "Transcribing…" + case .drafting: return "Drafting…" + case .draftReady: return "Draft ready" + case .failed: return note.statusReason ?? "Failed" + } + } + + private var statusColor: Color { + switch note.status { + case .draftReady: return .green + case .failed: return .red + default: return .secondary + } + } +} diff --git a/WordPress/JetpackWatch Watch App/Views/RecordView.swift b/WordPress/JetpackWatch Watch App/Views/RecordView.swift new file mode 100644 index 000000000000..4cb2ca1e031f --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Views/RecordView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +struct RecordView: View { + @EnvironmentObject private var env: AppEnvironment + @StateObject private var vm: RecordingViewModel + + @State private var showSitePicker = false + @State private var showHistory = false + @State private var recordError: String? + + init(env: AppEnvironment) { + _vm = StateObject(wrappedValue: RecordingViewModel( + recorder: env.audioRecorder, + store: env.noteStore, + siteCatalog: env.siteCatalog, + phoneBridge: env.phoneBridge + )) + } + + var body: some View { + VStack(spacing: 8) { + Button { + showSitePicker = true + } label: { + Text(env.siteCatalog.defaultSite?.name ?? "Choose site") + .font(.caption) + .lineLimit(1) + .truncationMode(.middle) + } + .buttonStyle(.plain) + + recordButton + + Button("History") { showHistory = true } + .font(.caption) + } + .padding(.vertical, 4) + .navigationDestination(isPresented: $showSitePicker) { SitePickerView() } + .navigationDestination(isPresented: $showHistory) { HistoryView() } + .alert("Couldn't record", isPresented: Binding( + get: { recordError != nil }, + set: { if !$0 { recordError = nil } } + )) { + Button("OK") { recordError = nil } + } message: { + Text(recordError ?? "") + } + } + + @ViewBuilder + private var recordButton: some View { + switch vm.state { + case .idle: + Button { + do { + try vm.startRecording() + } catch RecordingViewModelError.noDefaultSite { + recordError = "Pick a site first." + } catch { + recordError = "Couldn't start recording." + } + } label: { + Image(systemName: "mic.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 64) + .foregroundStyle(.red) + } + .buttonStyle(.plain) + .disabled(env.siteCatalog.defaultSiteID == nil) + + case .recording: + Button { + try? vm.stopRecording() + } label: { + Image(systemName: "stop.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 64) + .foregroundStyle(.red) + } + .buttonStyle(.plain) + } + } +} diff --git a/WordPress/JetpackWatch Watch App/Views/SitePickerView.swift b/WordPress/JetpackWatch Watch App/Views/SitePickerView.swift new file mode 100644 index 000000000000..09681ef91ab1 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Views/SitePickerView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct SitePickerView: View { + @EnvironmentObject private var env: AppEnvironment + @Environment(\.dismiss) private var dismiss + + var body: some View { + Group { + if env.siteCatalog.sites.isEmpty { + ContentUnavailableView( + "No sites", + systemImage: "globe.badge.chevron.backward", + description: Text("Add a site in Jetpack on your iPhone.") + ) + } else { + List(env.siteCatalog.sites) { site in + Button { select(site) } label: { + HStack { + Text(site.name) + Spacer() + if env.siteCatalog.defaultSiteID == site.id { + Image(systemName: "checkmark") + } + } + } + } + } + } + .navigationTitle("Site") + } + + private func select(_ site: Site) { + env.siteCatalog.setDefaultSiteID(site.id) + let bridge = env.phoneBridge + Task { await bridge.setDefaultSiteID(site.id) } + dismiss() + } +} From bfd2fb040e993ea6286c148cf08f7fc150bce334 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Wed, 13 May 2026 10:11:13 +0200 Subject: [PATCH 12/23] Fix 4: delete the auto-generated tautological test The Xcode-generated JetpackWatch_Watch_AppTests.swift had a single empty @Test func example() that was meant to be removed in Plan 1 Task 1. --- .../JetpackWatch_Watch_AppTests.swift | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 WordPress/JetpackWatch Watch AppTests/JetpackWatch_Watch_AppTests.swift diff --git a/WordPress/JetpackWatch Watch AppTests/JetpackWatch_Watch_AppTests.swift b/WordPress/JetpackWatch Watch AppTests/JetpackWatch_Watch_AppTests.swift deleted file mode 100644 index d5b060036b6d..000000000000 --- a/WordPress/JetpackWatch Watch AppTests/JetpackWatch_Watch_AppTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// JetpackWatch_Watch_AppTests.swift -// JetpackWatch Watch AppTests -// -// Created by Ricardo Ortiz on 12/05/2026. -// Copyright © 2026 WordPress. All rights reserved. -// - -import Testing -@testable import JetpackWatch_Watch_App - -struct JetpackWatch_Watch_AppTests { - - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - // Swift Testing Documentation - // https://developer.apple.com/documentation/testing - } - -} From f21ecb29497f11bf6e990d735c1e2efc37ca1f5e Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Wed, 13 May 2026 10:22:10 +0200 Subject: [PATCH 13/23] Fix 3: tighten PhoneBridge callback types to @MainActor @Sendable Plan 2's WCSession-backed impl will deliver callbacks from delegate threads. Annotating the closures in the protocol, MockPhoneBridge, and AppEnvironment.live() makes the isolation contract explicit and lets Swift 6 enforce it at every call site. --- WordPress/JetpackWatch Watch App/App/AppEnvironment.swift | 4 ++-- WordPress/JetpackWatch Watch App/Domain/PhoneBridge.swift | 8 ++++---- .../PhoneBridgeMockTests.swift | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift b/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift index 361f820b2246..76b7a0ce65a6 100644 --- a/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift +++ b/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift @@ -34,13 +34,13 @@ final class AppEnvironment: ObservableObject { let audioRecorder = AudioRecorder(rootURL: docs) let handoff = HandoffPublisher() - bridge.onSitesReceived = { sites in + bridge.onSitesReceived = { @MainActor @Sendable sites in siteCatalog.setSites(sites) if siteCatalog.defaultSiteID == nil, let first = sites.first { siteCatalog.setDefaultSiteID(first.id) } } - bridge.onNoteStateUpdate = { id, status, postID, reason in + bridge.onNoteStateUpdate = { @MainActor @Sendable id, status, postID, reason in guard var note = noteStore.notes.first(where: { $0.id == id }) else { return } note.status = status if let postID { note.postID = postID } diff --git a/WordPress/JetpackWatch Watch App/Domain/PhoneBridge.swift b/WordPress/JetpackWatch Watch App/Domain/PhoneBridge.swift index a15949696081..995ac46dcb25 100644 --- a/WordPress/JetpackWatch Watch App/Domain/PhoneBridge.swift +++ b/WordPress/JetpackWatch Watch App/Domain/PhoneBridge.swift @@ -21,17 +21,17 @@ protocol PhoneBridge: AnyObject { func deleteNote(_ id: UUID) async /// Called when the phone pushes a fresh site list. - var onSitesReceived: (([Site]) -> Void)? { get set } + var onSitesReceived: (@MainActor @Sendable ([Site]) -> Void)? { get set } /// Called when the phone pushes a state update for a note. /// Parameters: noteID, status, postID (if draft_ready), statusReason (if failed). - var onNoteStateUpdate: ((UUID, NoteStatus, Int64?, String?) -> Void)? { get set } + var onNoteStateUpdate: (@MainActor @Sendable (UUID, NoteStatus, Int64?, String?) -> Void)? { get set } } @MainActor final class MockPhoneBridge: PhoneBridge { - var onSitesReceived: (([Site]) -> Void)? - var onNoteStateUpdate: ((UUID, NoteStatus, Int64?, String?) -> Void)? + var onSitesReceived: (@MainActor @Sendable ([Site]) -> Void)? + var onNoteStateUpdate: (@MainActor @Sendable (UUID, NoteStatus, Int64?, String?) -> Void)? private(set) var handedOffNoteIDs: [UUID] = [] private(set) var retriedNoteIDs: [UUID] = [] diff --git a/WordPress/JetpackWatch Watch AppTests/PhoneBridgeMockTests.swift b/WordPress/JetpackWatch Watch AppTests/PhoneBridgeMockTests.swift index e52430713991..0ea9ab301b72 100644 --- a/WordPress/JetpackWatch Watch AppTests/PhoneBridgeMockTests.swift +++ b/WordPress/JetpackWatch Watch AppTests/PhoneBridgeMockTests.swift @@ -11,7 +11,7 @@ struct PhoneBridgeMockTests { let bridge = MockPhoneBridge(seedSites: sites) var received: [Site]? - bridge.onSitesReceived = { received = $0 } + bridge.onSitesReceived = { @MainActor @Sendable sites in received = sites } await bridge.start() #expect(received?.count == 1) From 476581be8de80b484d4339db87e42939c0927efb Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Wed, 13 May 2026 10:23:29 +0200 Subject: [PATCH 14/23] Fix 7: invalidate previous NSUserActivity before publishing a new one Each publishDraftReady call was creating a new NSUserActivity without invalidating the prior one, leaking instances when the user tapped multiple draft-ready rows. Added a test that sets an invalidationHandler on the first activity and asserts it fires before the second is set. --- .../Handoff/HandoffPublisher.swift | 1 + .../HandoffPublisherTests.swift | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/WordPress/JetpackWatch Watch App/Handoff/HandoffPublisher.swift b/WordPress/JetpackWatch Watch App/Handoff/HandoffPublisher.swift index 5a923e2d312e..27f618af5da0 100644 --- a/WordPress/JetpackWatch Watch App/Handoff/HandoffPublisher.swift +++ b/WordPress/JetpackWatch Watch App/Handoff/HandoffPublisher.swift @@ -7,6 +7,7 @@ final class HandoffPublisher { private(set) var currentActivity: NSUserActivity? func publishDraftReady(postID: Int64, siteID: Int64) { + currentActivity?.invalidate() let activity = NSUserActivity(activityType: Self.activityType) activity.title = "Open voice-note draft" activity.userInfo = ["postID": postID, "siteID": siteID] diff --git a/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift b/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift index d55bf0b86206..3b762281104c 100644 --- a/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift +++ b/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift @@ -22,4 +22,18 @@ struct HandoffPublisherTests { publisher.clear() #expect(publisher.currentActivity == nil) } + + @Test func publishing_a_second_activity_invalidates_the_first() { + let publisher = HandoffPublisher() + publisher.publishDraftReady(postID: 1, siteID: 1) + let first = publisher.currentActivity! + + var firstInvalidated = false + first.invalidationHandler = { firstInvalidated = true } + + publisher.publishDraftReady(postID: 2, siteID: 1) + + #expect(firstInvalidated) + #expect(publisher.currentActivity !== first) + } } From c7bef3827be0c8c1d853f112c6b554c63c7dee21 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Wed, 13 May 2026 10:26:19 +0200 Subject: [PATCH 15/23] Spec gap 2: map failure-reason codes to user-facing strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FailureReason enum with the 7 codes from the spec and a userFacingMessage property. NoteRowView now resolves the raw statusReason string through FailureReason instead of displaying the raw code. Unrecognised codes still fall through to "Failed". Tests cover all raw value → case mappings, non-empty messages, and nil for unknown codes. --- .../Domain/FailureReason.swift | 23 +++++++++++++++ .../Views/NoteRowView.swift | 7 ++++- .../FailureReasonTests.swift | 29 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 WordPress/JetpackWatch Watch App/Domain/FailureReason.swift create mode 100644 WordPress/JetpackWatch Watch AppTests/FailureReasonTests.swift diff --git a/WordPress/JetpackWatch Watch App/Domain/FailureReason.swift b/WordPress/JetpackWatch Watch App/Domain/FailureReason.swift new file mode 100644 index 000000000000..e3b377452a16 --- /dev/null +++ b/WordPress/JetpackWatch Watch App/Domain/FailureReason.swift @@ -0,0 +1,23 @@ +import Foundation + +nonisolated enum FailureReason: String, CaseIterable, Sendable { + case uploadError = "upload_error" + case transcriptionError = "transcription_error" + case draftError = "draft_error" + case siteForbidden = "site_forbidden" + case invalidAudio = "invalid_audio" + case timeout + case cancelled + + var userFacingMessage: String { + switch self { + case .uploadError: return "Couldn't upload — tap to retry" + case .transcriptionError: return "Transcription failed — tap to retry" + case .draftError: return "Draft generation failed — tap to retry" + case .siteForbidden: return "You can't post to this site" + case .invalidAudio: return "Recording was unreadable" + case .timeout: return "Took too long — tap to retry" + case .cancelled: return "Cancelled" + } + } +} diff --git a/WordPress/JetpackWatch Watch App/Views/NoteRowView.swift b/WordPress/JetpackWatch Watch App/Views/NoteRowView.swift index 7ae764b231a5..c402efd2ac07 100644 --- a/WordPress/JetpackWatch Watch App/Views/NoteRowView.swift +++ b/WordPress/JetpackWatch Watch App/Views/NoteRowView.swift @@ -36,7 +36,12 @@ struct NoteRowView: View { case .transcribing: return "Transcribing…" case .drafting: return "Drafting…" case .draftReady: return "Draft ready" - case .failed: return note.statusReason ?? "Failed" + case .failed: + if let reason = note.statusReason.flatMap(FailureReason.init(rawValue:)) { + return reason.userFacingMessage + } else { + return "Failed" + } } } diff --git a/WordPress/JetpackWatch Watch AppTests/FailureReasonTests.swift b/WordPress/JetpackWatch Watch AppTests/FailureReasonTests.swift new file mode 100644 index 000000000000..f2c7f3975e76 --- /dev/null +++ b/WordPress/JetpackWatch Watch AppTests/FailureReasonTests.swift @@ -0,0 +1,29 @@ +import Testing +import Foundation +@testable import JetpackWatch_Watch_App + +@Suite("FailureReason") +struct FailureReasonTests { + + @Test(arguments: [ + ("upload_error", FailureReason.uploadError), + ("transcription_error", FailureReason.transcriptionError), + ("draft_error", FailureReason.draftError), + ("site_forbidden", FailureReason.siteForbidden), + ("invalid_audio", FailureReason.invalidAudio), + ("timeout", FailureReason.timeout), + ("cancelled", FailureReason.cancelled), + ] as [(String, FailureReason)]) + func raw_value_parses_to_expected_case(rawValue: String, expected: FailureReason) { + #expect(FailureReason(rawValue: rawValue) == expected) + } + + @Test(arguments: FailureReason.allCases) + func each_case_has_non_empty_user_facing_message(reason: FailureReason) { + #expect(!reason.userFacingMessage.isEmpty) + } + + @Test func unknown_raw_value_parses_to_nil() { + #expect(FailureReason(rawValue: "unknown_reason") == nil) + } +} From dd788e97d195e8179f6bb7243b09007f6d27db8f Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Wed, 13 May 2026 10:27:33 +0200 Subject: [PATCH 16/23] Fix 1: NoteStore takes explicit audioRootURL, deletes audio on removal Pass audioRootURL separately from rootURL so the dependency is explicit in the API. delete(id:) and evictIfNeeded() now attempt to remove the associated .m4a file; a missing file is silently ignored, any other removal failure is logged as a warning. Two new tests cover the delete and eviction audio-cleanup paths. --- .../App/AppEnvironment.swift | 4 +- .../Storage/NoteStore.swift | 24 ++++++++-- .../NoteStoreTests.swift | 44 ++++++++++++++++++- .../RecordingViewModelTests.swift | 2 +- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift b/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift index 76b7a0ce65a6..5802a6ea729b 100644 --- a/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift +++ b/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift @@ -27,7 +27,7 @@ final class AppEnvironment: ObservableObject { static func live() -> AppEnvironment { let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - let noteStore = NoteStore(rootURL: docs) + let noteStore = NoteStore(rootURL: docs, audioRootURL: docs) let siteCatalog = SiteCatalog(rootURL: docs) let seed = AppEnvironment.developmentSeedSites let bridge = MockPhoneBridge(seedSites: seed) @@ -77,7 +77,7 @@ extension AppEnvironment { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("watch-preview-\(UUID().uuidString)") try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let noteStore = NoteStore(rootURL: tempDir) + let noteStore = NoteStore(rootURL: tempDir, audioRootURL: tempDir) let siteCatalog = SiteCatalog(rootURL: tempDir) siteCatalog.setSites(Site.previewSeed) siteCatalog.setDefaultSiteID(Site.previewSeed.first?.id) diff --git a/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift b/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift index f5779ecaa5aa..21bd56660aeb 100644 --- a/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift +++ b/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift @@ -1,5 +1,6 @@ import Foundation import Combine +import os enum NoteStoreError: Error, Equatable, Sendable { case notFound @@ -15,9 +16,11 @@ final class NoteStore: ObservableObject { @Published private(set) var notes: [VoiceNote] = [] private let fileURL: URL + private let audioRootURL: URL - init(rootURL: URL) { + init(rootURL: URL, audioRootURL: URL) { self.fileURL = rootURL.appendingPathComponent("notes.json") + self.audioRootURL = audioRootURL load() } @@ -38,6 +41,9 @@ final class NoteStore: ObservableObject { } func delete(id: UUID) throws { + if let note = notes.first(where: { $0.id == id }) { + removeAudioFile(for: note) + } notes.removeAll { $0.id == id } try save() } @@ -53,14 +59,26 @@ final class NoteStore: ObservableObject { .filter { $0.element.status.isTerminal } .sorted { $0.element.createdAt < $1.element.createdAt } var indicesToRemove = Set() - for (idx, _) in terminalSortedOldestFirst.prefix(overage) { - indicesToRemove.insert(idx) + for item in terminalSortedOldestFirst.prefix(overage) { + indicesToRemove.insert(item.offset) + removeAudioFile(for: item.element) } notes = notes.enumerated() .filter { !indicesToRemove.contains($0.offset) } .map(\.element) } + private func removeAudioFile(for note: VoiceNote) { + let audioURL = audioRootURL.appendingPathComponent(note.audioFilename) + do { + try FileManager.default.removeItem(at: audioURL) + } catch let error as NSError where error.code == NSFileNoSuchFileError { + // File never written (e.g. recording never completed) — expected, ignore. + } catch { + watchLogger.warning("NoteStore: failed to delete audio file \(note.audioFilename, privacy: .public): \(error, privacy: .public)") + } + } + private func load() { guard let data = try? Data(contentsOf: fileURL), let decoded = try? JSONDecoder().decode([VoiceNote].self, from: data) else { diff --git a/WordPress/JetpackWatch Watch AppTests/NoteStoreTests.swift b/WordPress/JetpackWatch Watch AppTests/NoteStoreTests.swift index 1e0e6ab70010..60cc9699e120 100644 --- a/WordPress/JetpackWatch Watch AppTests/NoteStoreTests.swift +++ b/WordPress/JetpackWatch Watch AppTests/NoteStoreTests.swift @@ -10,7 +10,7 @@ struct NoteStoreTests { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("NoteStoreTests-\(UUID().uuidString)") try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - return (NoteStore(rootURL: tempDir), tempDir) + return (NoteStore(rootURL: tempDir, audioRootURL: tempDir), tempDir) } private func makeNote( @@ -45,7 +45,7 @@ struct NoteStoreTests { #expect(store.notes.count == 1) #expect(store.notes.first?.id == note.id) - let reloaded = NoteStore(rootURL: tempDir) + let reloaded = NoteStore(rootURL: tempDir, audioRootURL: tempDir) #expect(reloaded.notes.count == 1) #expect(reloaded.notes.first?.id == note.id) } @@ -119,4 +119,44 @@ struct NoteStoreTests { } #expect(store.notes.count == 25) } + + @Test func delete_removes_associated_audio_file() throws { + let (store, tempDir) = makeStore() + let note = makeNote() + try store.add(note) + + let audioURL = tempDir.appendingPathComponent(note.audioFilename) + try Data("fake-audio".utf8).write(to: audioURL) + #expect(FileManager.default.fileExists(atPath: audioURL.path)) + + try store.delete(id: note.id) + + #expect(!FileManager.default.fileExists(atPath: audioURL.path)) + } + + @Test func eviction_removes_associated_audio_files() throws { + let (store, tempDir) = makeStore() + + var audioURLs: [URL] = [] + for i in 0..<21 { + let note = makeNote( + status: .draftReady, + createdAt: Date(timeIntervalSince1970: TimeInterval(i)) + ) + try store.add(note) + let audioURL = tempDir.appendingPathComponent(note.audioFilename) + try Data("fake-audio".utf8).write(to: audioURL) + audioURLs.append(audioURL) + } + + // 21 notes added — eviction runs after the last add, removing the oldest + #expect(store.notes.count == 20) + + // The first note (oldest, createdAt = 0) should have its audio file removed + #expect(!FileManager.default.fileExists(atPath: audioURLs[0].path)) + // The remaining notes' audio files should still exist + for url in audioURLs[1...] { + #expect(FileManager.default.fileExists(atPath: url.path)) + } + } } diff --git a/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift b/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift index 224664013d97..b87020eb78b5 100644 --- a/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift +++ b/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift @@ -16,7 +16,7 @@ struct RecordingViewModelTests { .appendingPathComponent("RecordingVMTests-\(UUID().uuidString)") try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let recorder = StubAudioRecorder(rootURL: tempDir) - let store = NoteStore(rootURL: tempDir) + let store = NoteStore(rootURL: tempDir, audioRootURL: tempDir) let bridge = MockPhoneBridge(seedSites: []) let catalog = SiteCatalog(rootURL: tempDir) if let siteID { From 9c599dc548222b8ecf4de30df1fc5c3a4c4bbede Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Wed, 13 May 2026 10:30:05 +0200 Subject: [PATCH 17/23] Fix 2: handle store.add failure in RecordingViewModel.finalize When store.add throws, the .m4a is already on disk with no VoiceNote to reference it. On failure: cancel the recorder (deletes the partial file), log the error, set lastError for the view to surface, and return to idle. NoteStore is now an open class (not final) so tests can inject a FailingNoteStore subclass. New test verifies state == .idle and that the recorder's cancel(id:) was called. --- .../Storage/NoteStore.swift | 2 +- .../ViewModels/RecordingViewModel.swift | 12 +++++- .../RecordingViewModelTests.swift | 41 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift b/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift index 21bd56660aeb..9ba37531481b 100644 --- a/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift +++ b/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift @@ -10,7 +10,7 @@ enum NoteStoreError: Error, Equatable, Sendable { /// on every mutation. Eviction: when over `cap`, oldest terminal notes go first; /// active notes are never auto-evicted. @MainActor -final class NoteStore: ObservableObject { +class NoteStore: ObservableObject { static let cap = 20 @Published private(set) var notes: [VoiceNote] = [] diff --git a/WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift b/WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift index 62f70524e1a2..31036b1b8ba2 100644 --- a/WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift +++ b/WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift @@ -4,6 +4,7 @@ import Combine enum RecordingViewModelError: Error, Equatable, Sendable { case noDefaultSite case alreadyRecording + case storeFailed(String) } enum RecordingState: Equatable, Sendable { @@ -14,6 +15,7 @@ enum RecordingState: Equatable, Sendable { @MainActor final class RecordingViewModel: ObservableObject { @Published private(set) var state: RecordingState = .idle + @Published private(set) var lastError: RecordingViewModelError? private let recorder: AudioRecorder private let store: NoteStore @@ -72,7 +74,15 @@ final class RecordingViewModel: ObservableObject { statusReason: nil, postID: nil ) - try store.add(note) + do { + try store.add(note) + } catch { + recorder.cancel(id: id) + watchLogger.error("RecordingViewModel: store.add failed: \(error, privacy: .public)") + lastError = .storeFailed(error.localizedDescription) + state = .idle + return + } let audioURL = recorder.fileURL(for: id) let bridge = phoneBridge Task { await bridge.handOff(noteID: id, audioURL: audioURL, siteID: siteID) } diff --git a/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift b/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift index b87020eb78b5..4dd0c15ac4b3 100644 --- a/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift +++ b/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift @@ -81,6 +81,47 @@ struct RecordingViewModelTests { #expect(store.notes.count == 1) #expect(store.notes.first?.status == .queued) } + + @Test func stopRecording_with_failing_store_cleans_up_audio_and_returns_to_idle() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("RecordingVMFailTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let recorder = StubAudioRecorder(rootURL: tempDir) + let store = FailingNoteStore(rootURL: tempDir, audioRootURL: tempDir) + let bridge = MockPhoneBridge(seedSites: []) + let catalog = SiteCatalog(rootURL: tempDir) + catalog.setSites([Site(id: 1, name: "Test")]) + catalog.setDefaultSiteID(1) + let vm = RecordingViewModel( + recorder: recorder, + store: store, + siteCatalog: catalog, + phoneBridge: bridge + ) + + try vm.startRecording() + let recordingID: UUID + if case let .recording(id, _) = vm.state { + recordingID = id + } else { + Issue.record("expected .recording"); return + } + + try vm.stopRecording() + + if case .idle = vm.state {} else { Issue.record("expected .idle after store failure"); return } + #expect(recorder.cancelled.contains(recordingID)) + #expect(vm.lastError != nil) + } +} + +@MainActor +final class FailingNoteStore: NoteStore { + private enum StoreError: Error { case injectedFailure } + + override func add(_ note: VoiceNote) throws { + throw StoreError.injectedFailure + } } @MainActor From 17cac3b945c75b573b503dc411cfb06bcebbcf77 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Wed, 13 May 2026 10:30:23 +0200 Subject: [PATCH 18/23] Spec gap 1: propagate swipe-delete from Watch to Phone via bridge The swipe-delete action was only removing the note from the local store. Also dispatch bridge.deleteNote so the phone can clean up its end-to-end state as the spec requires. --- WordPress/JetpackWatch Watch App/Views/HistoryView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/WordPress/JetpackWatch Watch App/Views/HistoryView.swift b/WordPress/JetpackWatch Watch App/Views/HistoryView.swift index 5bf3693c28e4..b8e899368772 100644 --- a/WordPress/JetpackWatch Watch App/Views/HistoryView.swift +++ b/WordPress/JetpackWatch Watch App/Views/HistoryView.swift @@ -20,6 +20,8 @@ struct HistoryView: View { .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { try? env.noteStore.delete(id: note.id) + let bridge = env.phoneBridge + Task { await bridge.deleteNote(note.id) } } label: { Label("Delete", systemImage: "trash") } From a1ab418a340506de59b5f1059e642d6b833c5516 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Wed, 13 May 2026 10:30:55 +0200 Subject: [PATCH 19/23] Fix 5: gate MockPhoneBridge to DEBUG, fatalError in release live() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 1 is dev-only. Wrapping the entire live() wiring in #if DEBUG and adding a fatalError in the #else branch makes it impossible to silently ship a broken release build — the compiler will refuse to run it. Removed the now-redundant developmentSeedSites private extension since the seed is only referenced in the DEBUG path. --- .../App/AppEnvironment.swift | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift b/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift index 5802a6ea729b..c3806cf5cd5b 100644 --- a/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift +++ b/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift @@ -26,11 +26,11 @@ final class AppEnvironment: ObservableObject { } static func live() -> AppEnvironment { + #if DEBUG let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let noteStore = NoteStore(rootURL: docs, audioRootURL: docs) let siteCatalog = SiteCatalog(rootURL: docs) - let seed = AppEnvironment.developmentSeedSites - let bridge = MockPhoneBridge(seedSites: seed) + let bridge = MockPhoneBridge(seedSites: Site.previewSeed) let audioRecorder = AudioRecorder(rootURL: docs) let handoff = HandoffPublisher() @@ -56,17 +56,8 @@ final class AppEnvironment: ObservableObject { audioRecorder: audioRecorder, handoffPublisher: handoff ) - } -} - -private extension AppEnvironment { - /// Seed shown while Plan 1 uses MockPhoneBridge. Plan 2 swaps this for - /// real sites received over WatchConnectivity. - static var developmentSeedSites: [Site] { - #if DEBUG - return Site.previewSeed #else - return [] + fatalError("JetpackWatch Watch App is not yet shippable: Plan 2 introduces the WCSession-backed PhoneBridge. Build with the Debug configuration.") #endif } } From 41d1d79215da21406a20f1597b2418ad5126f727 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Wed, 13 May 2026 10:31:56 +0200 Subject: [PATCH 20/23] Fix 6: introduce watchLogger and log at all error sites Declare a module-level Logger (subsystem: com.automattic.jetpack.watch) per AGENTS.md guidance. Replace silent try? swallows with do/catch + watchLogger.error at: NoteStore.save, SiteCatalog.setSites/setDefaultSiteID, HistoryView swipe-delete, NoteStore audio-file removal (warning), and RecordingViewModel store-add failure (error). RecordView's try? vm.stopRecording() is intentionally left alone. --- .../JetpackWatch Watch App/App/AppEnvironment.swift | 3 +++ .../JetpackWatch Watch App/Storage/NoteStore.swift | 10 +++++++--- .../JetpackWatch Watch App/Storage/SiteCatalog.swift | 12 ++++++++++-- .../JetpackWatch Watch App/Views/HistoryView.swift | 6 +++++- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift b/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift index c3806cf5cd5b..4d784942fb9a 100644 --- a/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift +++ b/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift @@ -1,6 +1,9 @@ import Foundation import Combine import SwiftUI +import os + +let watchLogger = Logger(subsystem: "com.automattic.jetpack.watch", category: "general") @MainActor final class AppEnvironment: ObservableObject { diff --git a/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift b/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift index 9ba37531481b..ca56d6b2fe1b 100644 --- a/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift +++ b/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift @@ -1,6 +1,5 @@ import Foundation import Combine -import os enum NoteStoreError: Error, Equatable, Sendable { case notFound @@ -89,7 +88,12 @@ class NoteStore: ObservableObject { } private func save() throws { - let data = try JSONEncoder().encode(notes) - try data.write(to: fileURL, options: .atomic) + do { + let data = try JSONEncoder().encode(notes) + try data.write(to: fileURL, options: .atomic) + } catch { + watchLogger.error("NoteStore save failed: \(error, privacy: .public)") + throw error + } } } diff --git a/WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift b/WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift index 4c3cda07a646..0de3be36df56 100644 --- a/WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift +++ b/WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift @@ -24,12 +24,20 @@ final class SiteCatalog: ObservableObject { func setSites(_ sites: [Site]) { self.sites = sites - try? save(sites, to: sitesURL) + do { + try save(sites, to: sitesURL) + } catch { + watchLogger.error("SiteCatalog sites save failed: \(error, privacy: .public)") + } } func setDefaultSiteID(_ id: Int64?) { self.defaultSiteID = id - try? save(id, to: defaultURL) + do { + try save(id, to: defaultURL) + } catch { + watchLogger.error("SiteCatalog default site save failed: \(error, privacy: .public)") + } } private func load() { diff --git a/WordPress/JetpackWatch Watch App/Views/HistoryView.swift b/WordPress/JetpackWatch Watch App/Views/HistoryView.swift index b8e899368772..fbdcb55fc809 100644 --- a/WordPress/JetpackWatch Watch App/Views/HistoryView.swift +++ b/WordPress/JetpackWatch Watch App/Views/HistoryView.swift @@ -19,7 +19,11 @@ struct HistoryView: View { .disabled(note.status != .draftReady) .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { - try? env.noteStore.delete(id: note.id) + do { + try env.noteStore.delete(id: note.id) + } catch { + watchLogger.error("HistoryView: delete note failed: \(error, privacy: .public)") + } let bridge = env.phoneBridge Task { await bridge.deleteNote(note.id) } } label: { From 9325672a7bc1cb9249a74886204ca97dd1bb33c2 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Wed, 13 May 2026 10:33:30 +0200 Subject: [PATCH 21/23] Fix 6 (follow-up): add import os to files using watchLogger interpolation OSLog privacy interpolation (\(x, privacy: .public)) requires import os at each call site even when watchLogger itself is declared elsewhere. --- WordPress/JetpackWatch Watch App/Storage/NoteStore.swift | 1 + WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift | 1 + .../JetpackWatch Watch App/ViewModels/RecordingViewModel.swift | 1 + WordPress/JetpackWatch Watch App/Views/HistoryView.swift | 1 + 4 files changed, 4 insertions(+) diff --git a/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift b/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift index ca56d6b2fe1b..535725e84819 100644 --- a/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift +++ b/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift @@ -1,5 +1,6 @@ import Foundation import Combine +import os enum NoteStoreError: Error, Equatable, Sendable { case notFound diff --git a/WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift b/WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift index 0de3be36df56..990074f27169 100644 --- a/WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift +++ b/WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift @@ -1,5 +1,6 @@ import Foundation import Combine +import os /// Caches the list of sites and the user's selected default site. /// JSON-backed: two files inside the root directory. diff --git a/WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift b/WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift index 31036b1b8ba2..7659f5958d2c 100644 --- a/WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift +++ b/WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift @@ -1,5 +1,6 @@ import Foundation import Combine +import os enum RecordingViewModelError: Error, Equatable, Sendable { case noDefaultSite diff --git a/WordPress/JetpackWatch Watch App/Views/HistoryView.swift b/WordPress/JetpackWatch Watch App/Views/HistoryView.swift index fbdcb55fc809..f34d62f272b1 100644 --- a/WordPress/JetpackWatch Watch App/Views/HistoryView.swift +++ b/WordPress/JetpackWatch Watch App/Views/HistoryView.swift @@ -1,4 +1,5 @@ import SwiftUI +import os struct HistoryView: View { @EnvironmentObject private var env: AppEnvironment From 4a600619c1ffdaeeab30f7f0a2611d0dfe8f9a98 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Wed, 13 May 2026 10:34:09 +0200 Subject: [PATCH 22/23] Fix 7 (follow-up): use isValid instead of invalidationHandler in test NSUserActivity.invalidationHandler is not available on watchOS. Use isValid (which becomes false after invalidate()) and a reference inequality check instead. --- .../HandoffPublisherTests.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift b/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift index 3b762281104c..26fc8b83bfe9 100644 --- a/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift +++ b/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift @@ -28,12 +28,11 @@ struct HandoffPublisherTests { publisher.publishDraftReady(postID: 1, siteID: 1) let first = publisher.currentActivity! - var firstInvalidated = false - first.invalidationHandler = { firstInvalidated = true } - publisher.publishDraftReady(postID: 2, siteID: 1) - #expect(firstInvalidated) + // The publisher must have replaced the reference (new activity, not the same object). #expect(publisher.currentActivity !== first) + // The first activity must have been invalidated (isValid becomes false after invalidate()). + #expect(!first.isValid) } } From 2e67744b16bc352c8c4b4a812afa8ed705d35bf3 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Wed, 13 May 2026 10:35:07 +0200 Subject: [PATCH 23/23] Fix 7 (follow-up 2): drop isValid assertion, not available on watchOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSUserActivity.isValid and invalidationHandler are iOS-only. Assert reference inequality and the updated postID on the second activity instead — sufficient to verify the invalidate+replace path executed. --- .../HandoffPublisherTests.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift b/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift index 26fc8b83bfe9..e3c1c5db1470 100644 --- a/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift +++ b/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift @@ -23,16 +23,17 @@ struct HandoffPublisherTests { #expect(publisher.currentActivity == nil) } - @Test func publishing_a_second_activity_invalidates_the_first() { + @Test func publishing_a_second_activity_replaces_the_first() { let publisher = HandoffPublisher() publisher.publishDraftReady(postID: 1, siteID: 1) let first = publisher.currentActivity! publisher.publishDraftReady(postID: 2, siteID: 1) + let second = publisher.currentActivity! - // The publisher must have replaced the reference (new activity, not the same object). - #expect(publisher.currentActivity !== first) - // The first activity must have been invalidated (isValid becomes false after invalidate()). - #expect(!first.isValid) + // The current activity must be a fresh object (the prior one was invalidated and replaced). + #expect(second !== first) + // The new activity carries the updated postID. + #expect(second.userInfo?["postID"] as? Int64 == 2) } }