Skip to content

JetpackWatch Watch App scaffold (Plan 1 of 3 — not ready to merge)#25557

Draft
rcrdortiz wants to merge 23 commits into
wordpress-mobile:trunkfrom
rcrdortiz:watch/scaffold
Draft

JetpackWatch Watch App scaffold (Plan 1 of 3 — not ready to merge)#25557
rcrdortiz wants to merge 23 commits into
wordpress-mobile:trunkfrom
rcrdortiz:watch/scaffold

Conversation

@rcrdortiz
Copy link
Copy Markdown

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

  • New JetpackWatch Watch App target embedded in the Jetpack iOS app, watchOS 26.5, SwiftUI + MVVM, Swift Testing.
  • Three screens: Record (root), History (with swipe-delete and retry affordance), Site Picker.
  • Five internal components: 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 (NSUserActivity for opening drafts on the iPhone).
  • RecordingViewModel state machine; failure path cancels the partial audio and surfaces the error.
  • Composition root in AppEnvironment; release wiring fails loud so we cannot accidentally ship a MockPhoneBridge-only build.
  • Structured logging via os.Logger at every error site.
  • 27 unit tests across 8 suites — all green.

Deliberate constraints

  • MockPhoneBridge is gated to #if DEBUG. AppEnvironment.live() in release fatalErrors. Plan 2 introduces the WCSession-backed implementation that replaces it.
  • PhoneBridge callbacks are typed @MainActor @Sendable so Plan 2's delegate-thread invocations are safe under Swift 6 strict concurrency.
  • Authority split is enforced structurally: no Plan 1 code path moves a note past .queued locally — the phone is the only writer of .uploading and later. Today that path is exercised only through MockPhoneBridge.onNoteStateUpdate.

Deferred to later plans (not bugs)

Plan 2 (iOS companion pipeline):

  • VoiceNoteSession (WCSessionDelegate), VoiceNoteCoordinator state machine, VoiceNoteUploader, VoiceNoteJobPoller, VoiceNoteStore (Core Data).
  • Replay-on-reachability path for queued audio — the PhoneBridge protocol does not yet expose this seam.
  • A dev affordance on MockPhoneBridge to simulate state transitions so the History UI and Handoff path are exercisable e2e without the real phone implementation.

Plan 3 (end-to-end glue):

  • Settings → "Voice notes default site" on the iOS side; App Group identifier coordination with mobile platform team.
  • Handoff handler on the iOS side that opens the draft editor for the com.automattic.jetpack.voice-note-draft activity.
  • Real WP.com endpoint integration (server-side spec lives outside this repo and needs its own brainstorm pass before implementation).
  • Manual end-to-end test matrix on the simulator.

Release engineering (separate workstream):

  • Provisioning profile for the Watch target; App Store Connect listing; screenshots.

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).
  • Manual on watchOS 26.5 simulator: cold launch → Record screen shows three seeded sites; tap site → Site Picker; tap mic → grant permission → tap stop → History shows Queued note; force-quit and relaunch → note persists; swipe-left → delete works; m4a file removed from documents directory.
  • Verify the JetpackWatch app is embedded in the Jetpack iOS target's bundle (Embed Watch Content build phase carries the JetpackWatch Watch App.app reference).

Notes for reviewers

  • The Watch target uses Xcode 16's PBXFileSystemSynchronizedRootGroup — new source files auto-compile without project.pbxproj edits. The exception set excludes only Info.plist (which we created manually so we could declare NSUserActivityTypes).
  • AppEnvironment declares nonisolated let objectWillChange = ObservableObjectPublisher() explicitly because of the project's InferIsolatedConformances upcoming feature combined with @StateObject ownership at the app root. The same workaround is not needed on consumed ObservableObjects.
  • The JetpackWatch_Watch_AppUITests target'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.

rcrdortiz added 23 commits May 12, 2026 13:25
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant