JetpackWatch Watch App scaffold (Plan 1 of 3 — not ready to merge)#25557
Draft
rcrdortiz wants to merge 23 commits into
Draft
JetpackWatch Watch App scaffold (Plan 1 of 3 — not ready to merge)#25557rcrdortiz wants to merge 23 commits into
rcrdortiz wants to merge 23 commits into
Conversation
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.
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.
Codable model used by the Watch-side store. NoteStatus encodes the full state machine; isTerminal/isActive helpers feed UI state.
JSON-backed @mainactor ObservableObject. 20-note cap with eviction of oldest terminal notes (active notes are never evicted).
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.
Caches site list and default-site selection on disk. Hydrated from the phone via PhoneBridge in Plan 2; seeded by MockPhoneBridge in Plan 1.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
…tion OSLog privacy interpolation (\(x, privacy: .public)) requires import os at each call site even when watchLogger itself is declared elsewhere.
NSUserActivity.invalidationHandler is not available on watchOS. Use isValid (which becomes false after invalidate()) and a reference inequality check instead.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR introduces a new JetpackWatch Watch App target inside the workspace and a self-contained Watch-side experience that runs end-to-end against a mock phone bridge. It is Plan 1 of 3 in a larger feature — voice-note dictation from Apple Watch that becomes an AI-drafted WordPress.com post. The other two plans (iOS companion pipeline; end-to-end glue + real WP.com endpoint) are not in scope here.
This is a draft, not for merge as-is. The release build deliberately
fatalErrors — Plan 1 is dev-only. See "Deferred to later plans" below.What's in the box
JetpackWatch Watch Apptarget embedded in the Jetpack iOS app, watchOS 26.5, SwiftUI + MVVM, Swift Testing.AudioRecorder(AAC m4a, 32 kbps mono, 16 kHz, 5-min hard cap),NoteStore(JSON persistence with 20-note cap and oldest-terminal eviction; deletes m4a alongside note),SiteCatalog(cached site list + default selection),PhoneBridge(protocol seam +MockPhoneBridge),HandoffPublisher(NSUserActivityfor opening drafts on the iPhone).RecordingViewModelstate machine; failure path cancels the partial audio and surfaces the error.AppEnvironment; release wiring fails loud so we cannot accidentally ship aMockPhoneBridge-only build.os.Loggerat every error site.Deliberate constraints
MockPhoneBridgeis gated to#if DEBUG.AppEnvironment.live()in releasefatalErrors. Plan 2 introduces theWCSession-backed implementation that replaces it.PhoneBridgecallbacks are typed@MainActor @Sendableso Plan 2's delegate-thread invocations are safe under Swift 6 strict concurrency..queuedlocally — the phone is the only writer of.uploadingand later. Today that path is exercised only throughMockPhoneBridge.onNoteStateUpdate.Deferred to later plans (not bugs)
Plan 2 (iOS companion pipeline):
VoiceNoteSession(WCSessionDelegate),VoiceNoteCoordinatorstate machine,VoiceNoteUploader,VoiceNoteJobPoller,VoiceNoteStore(Core Data).PhoneBridgeprotocol does not yet expose this seam.MockPhoneBridgeto simulate state transitions so the History UI and Handoff path are exercisable e2e without the real phone implementation.Plan 3 (end-to-end glue):
com.automattic.jetpack.voice-note-draftactivity.Release engineering (separate workstream):
Test plan
xcodebuild test -workspace WordPress.xcworkspace -scheme "JetpackWatch Watch App" -destination "platform=watchOS Simulator,name=Apple Watch Series 11 (46mm)" CODE_SIGNING_ALLOWED=NO— all 8 unit-test suites pass (VoiceNoteTests,NoteStoreTests,AudioRecorderTests,SiteCatalogTests,PhoneBridgeMockTests,HandoffPublisherTests,RecordingViewModelTests,FailureReasonTests).Queuednote; force-quit and relaunch → note persists; swipe-left → delete works; m4a file removed from documents directory.Embed Watch Contentbuild phase carries theJetpackWatch Watch App.appreference).Notes for reviewers
PBXFileSystemSynchronizedRootGroup— new source files auto-compile withoutproject.pbxprojedits. The exception set excludes onlyInfo.plist(which we created manually so we could declareNSUserActivityTypes).AppEnvironmentdeclaresnonisolated let objectWillChange = ObservableObjectPublisher()explicitly because of the project'sInferIsolatedConformancesupcoming feature combined with@StateObjectownership at the app root. The same workaround is not needed on consumedObservableObjects.JetpackWatch_Watch_AppUITeststarget's runner fails to launch on the simulator (FBSOpenApplicationErrorDomain/ "Unknown application display identifier"). This appears to be a pre-existing simulator infrastructure issue and not a regression from this PR — the empty UITests target was generated by Xcode's wizard alongside the Watch app target.