Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1a86f28
Add JetpackWatch Watch App target scaffolding
rcrdortiz May 12, 2026
34d785f
Scaffold JetpackWatch Watch App with RootView
rcrdortiz May 12, 2026
984be0a
Add VoiceNote model and NoteStatus enum
rcrdortiz May 12, 2026
37d9fa8
Add NoteStore for Watch-side voice note persistence
rcrdortiz May 12, 2026
5da221d
Add AudioRecorder for Watch voice capture
rcrdortiz May 12, 2026
7e8b5d3
Add Site model and SiteCatalog
rcrdortiz May 12, 2026
9910aa9
Add PhoneBridge protocol and MockPhoneBridge
rcrdortiz May 12, 2026
52bf659
Add HandoffPublisher for draft-ready handoff
rcrdortiz May 12, 2026
ea3140b
Add RecordingViewModel for record/stop coordination
rcrdortiz May 12, 2026
1e3e8ea
Wire AppEnvironment composition root
rcrdortiz May 12, 2026
ae25497
Add RecordView, HistoryView, NoteRowView, SitePickerView
rcrdortiz May 12, 2026
bfd2fb0
Fix 4: delete the auto-generated tautological test
rcrdortiz May 13, 2026
f21ecb2
Fix 3: tighten PhoneBridge callback types to @MainActor @Sendable
rcrdortiz May 13, 2026
476581b
Fix 7: invalidate previous NSUserActivity before publishing a new one
rcrdortiz May 13, 2026
c7bef38
Spec gap 2: map failure-reason codes to user-facing strings
rcrdortiz May 13, 2026
dd788e9
Fix 1: NoteStore takes explicit audioRootURL, deletes audio on removal
rcrdortiz May 13, 2026
9c599dc
Fix 2: handle store.add failure in RecordingViewModel.finalize
rcrdortiz May 13, 2026
17cac3b
Spec gap 1: propagate swipe-delete from Watch to Phone via bridge
rcrdortiz May 13, 2026
a1ab418
Fix 5: gate MockPhoneBridge to DEBUG, fatalError in release live()
rcrdortiz May 13, 2026
41d1d79
Fix 6: introduce watchLogger and log at all error sites
rcrdortiz May 13, 2026
9325672
Fix 6 (follow-up): add import os to files using watchLogger interpola…
rcrdortiz May 13, 2026
4a60061
Fix 7 (follow-up): use isValid instead of invalidationHandler in test
rcrdortiz May 13, 2026
2e67744
Fix 7 (follow-up 2): drop isValid assertion, not available on watchOS
rcrdortiz May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions WordPress/JetpackWatch Watch App/App/AppEnvironment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Foundation
import Combine
import SwiftUI
import os

let watchLogger = Logger(subsystem: "com.automattic.jetpack.watch", category: "general")

@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 {
#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 bridge = MockPhoneBridge(seedSites: Site.previewSeed)
let audioRecorder = AudioRecorder(rootURL: docs)
let handoff = HandoffPublisher()

bridge.onSitesReceived = { @MainActor @Sendable sites in
siteCatalog.setSites(sites)
if siteCatalog.defaultSiteID == nil, let first = sites.first {
siteCatalog.setDefaultSiteID(first.id)
}
}
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 }
note.statusReason = reason
try? noteStore.update(note)
}
Task { await bridge.start() }

return AppEnvironment(
noteStore: noteStore,
siteCatalog: siteCatalog,
phoneBridge: bridge,
audioRecorder: audioRecorder,
handoffPublisher: handoff
)
#else
fatalError("JetpackWatch Watch App is not yet shippable: Plan 2 introduces the WCSession-backed PhoneBridge. Build with the Debug configuration.")
#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, audioRootURL: 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
17 changes: 17 additions & 0 deletions WordPress/JetpackWatch Watch App/App/RootView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import SwiftUI

struct RootView: View {
@EnvironmentObject private var env: AppEnvironment

var body: some View {
NavigationStack {
RecordView(env: env)
.navigationTitle("Jetpack")
}
}
}

#Preview {
RootView()
.environmentObject(AppEnvironment.preview())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
107 changes: 107 additions & 0 deletions WordPress/JetpackWatch Watch App/Audio/AudioRecorder.swift
Original file line number Diff line number Diff line change
@@ -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?()
}
}
}
23 changes: 23 additions & 0 deletions WordPress/JetpackWatch Watch App/Domain/FailureReason.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
66 changes: 66 additions & 0 deletions WordPress/JetpackWatch Watch App/Domain/PhoneBridge.swift
Original file line number Diff line number Diff line change
@@ -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: (@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: (@MainActor @Sendable (UUID, NoteStatus, Int64?, String?) -> Void)? { get set }
}

@MainActor
final class MockPhoneBridge: PhoneBridge {
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] = []
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)
}
}
16 changes: 16 additions & 0 deletions WordPress/JetpackWatch Watch App/Domain/Site.swift
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions WordPress/JetpackWatch Watch App/Domain/VoiceNote.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading