diff --git a/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift b/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift
new file mode 100644
index 000000000000..4d784942fb9a
--- /dev/null
+++ b/WordPress/JetpackWatch Watch App/App/AppEnvironment.swift
@@ -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
diff --git a/WordPress/JetpackWatch Watch App/App/RootView.swift b/WordPress/JetpackWatch Watch App/App/RootView.swift
new file mode 100644
index 000000000000..4590816d8cf4
--- /dev/null
+++ b/WordPress/JetpackWatch Watch App/App/RootView.swift
@@ -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())
+}
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/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 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/Domain/PhoneBridge.swift b/WordPress/JetpackWatch Watch App/Domain/PhoneBridge.swift
new file mode 100644
index 000000000000..995ac46dcb25
--- /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: (@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)
+ }
+}
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/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 App/Handoff/HandoffPublisher.swift b/WordPress/JetpackWatch Watch App/Handoff/HandoffPublisher.swift
new file mode 100644
index 000000000000..27f618af5da0
--- /dev/null
+++ b/WordPress/JetpackWatch Watch App/Handoff/HandoffPublisher.swift
@@ -0,0 +1,24 @@
+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) {
+ currentActivity?.invalidate()
+ 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 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..3be561dcf135
--- /dev/null
+++ b/WordPress/JetpackWatch Watch App/JetpackWatchApp.swift
@@ -0,0 +1,13 @@
+import SwiftUI
+
+@main
+struct JetpackWatchApp: App {
+ @StateObject private var env = AppEnvironment.live()
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ .environmentObject(env)
+ }
+ }
+}
diff --git a/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift b/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift
new file mode 100644
index 000000000000..535725e84819
--- /dev/null
+++ b/WordPress/JetpackWatch Watch App/Storage/NoteStore.swift
@@ -0,0 +1,100 @@
+import Foundation
+import Combine
+import os
+
+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
+class NoteStore: ObservableObject {
+ static let cap = 20
+
+ @Published private(set) var notes: [VoiceNote] = []
+
+ private let fileURL: URL
+ private let audioRootURL: URL
+
+ init(rootURL: URL, audioRootURL: URL) {
+ self.fileURL = rootURL.appendingPathComponent("notes.json")
+ self.audioRootURL = audioRootURL
+ 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 {
+ if let note = notes.first(where: { $0.id == id }) {
+ removeAudioFile(for: note)
+ }
+ 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 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 {
+ return
+ }
+ notes = decoded
+ sortNewestFirst()
+ }
+
+ private func save() throws {
+ 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
new file mode 100644
index 000000000000..990074f27169
--- /dev/null
+++ b/WordPress/JetpackWatch Watch App/Storage/SiteCatalog.swift
@@ -0,0 +1,59 @@
+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.
+@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
+ do {
+ try save(sites, to: sitesURL)
+ } catch {
+ watchLogger.error("SiteCatalog sites save failed: \(error, privacy: .public)")
+ }
+ }
+
+ func setDefaultSiteID(_ id: Int64?) {
+ self.defaultSiteID = id
+ do {
+ try save(id, to: defaultURL)
+ } catch {
+ watchLogger.error("SiteCatalog default site save failed: \(error, privacy: .public)")
+ }
+ }
+
+ 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 App/ViewModels/RecordingViewModel.swift b/WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift
new file mode 100644
index 000000000000..7659f5958d2c
--- /dev/null
+++ b/WordPress/JetpackWatch Watch App/ViewModels/RecordingViewModel.swift
@@ -0,0 +1,92 @@
+import Foundation
+import Combine
+import os
+
+enum RecordingViewModelError: Error, Equatable, Sendable {
+ case noDefaultSite
+ case alreadyRecording
+ case storeFailed(String)
+}
+
+enum RecordingState: Equatable, Sendable {
+ case idle
+ case recording(noteID: UUID, startedAt: Date)
+}
+
+@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
+ 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
+ )
+ 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) }
+ state = .idle
+ }
+}
diff --git a/WordPress/JetpackWatch Watch App/Views/HistoryView.swift b/WordPress/JetpackWatch Watch App/Views/HistoryView.swift
new file mode 100644
index 000000000000..f34d62f272b1
--- /dev/null
+++ b/WordPress/JetpackWatch Watch App/Views/HistoryView.swift
@@ -0,0 +1,53 @@
+import SwiftUI
+import os
+
+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) {
+ 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: {
+ 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..c402efd2ac07
--- /dev/null
+++ b/WordPress/JetpackWatch Watch App/Views/NoteRowView.swift
@@ -0,0 +1,55 @@
+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:
+ if let reason = note.statusReason.flatMap(FailureReason.init(rawValue:)) {
+ return reason.userFacingMessage
+ } else {
+ return "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()
+ }
+}
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)
+ }
+}
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)
+ }
+}
diff --git a/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift b/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift
new file mode 100644
index 000000000000..e3c1c5db1470
--- /dev/null
+++ b/WordPress/JetpackWatch Watch AppTests/HandoffPublisherTests.swift
@@ -0,0 +1,39 @@
+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)
+ }
+
+ @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 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)
+ }
+}
diff --git a/WordPress/JetpackWatch Watch AppTests/NoteStoreTests.swift b/WordPress/JetpackWatch Watch AppTests/NoteStoreTests.swift
new file mode 100644
index 000000000000..60cc9699e120
--- /dev/null
+++ b/WordPress/JetpackWatch Watch AppTests/NoteStoreTests.swift
@@ -0,0 +1,162 @@
+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, audioRootURL: 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, audioRootURL: 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)
+ }
+
+ @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/PhoneBridgeMockTests.swift b/WordPress/JetpackWatch Watch AppTests/PhoneBridgeMockTests.swift
new file mode 100644
index 000000000000..0ea9ab301b72
--- /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 = { @MainActor @Sendable sites in received = sites }
+ 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))
+ }
+}
diff --git a/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift b/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift
new file mode 100644
index 000000000000..4dd0c15ac4b3
--- /dev/null
+++ b/WordPress/JetpackWatch Watch AppTests/RecordingViewModelTests.swift
@@ -0,0 +1,152 @@
+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, audioRootURL: 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)
+ }
+
+ @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
+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?()
+ }
+}
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)
+ }
+}
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)
+ }
+ }
+}
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..ca17c82d62c7 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 = ""; };
@@ -1114,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 */
@@ -1250,6 +1293,24 @@
path = WordPressKitTests;
sourceTree = "";
};
+ 9425206B2FB33FBE0027445A /* JetpackWatch Watch App */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ D10AEBD1535F4CC3B0EE9E3E /* Exceptions for "JetpackWatch Watch App" folder in "JetpackWatch Watch App" target */,
+ );
+ 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 +1525,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 +1674,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 +1719,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 +2536,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 +2642,7 @@
FABB26402602FC2C00C8785C /* Embed Foundation Extensions */,
FABB264C2602FC2C00C8785C /* Copy Gutenberg JS */,
4AD9555E2C21716A00D0EEFA /* Embed Frameworks */,
+ 9425208B2FB33FC00027445A /* Embed Watch Content */,
);
buildRules = (
);
@@ -2498,6 +2655,7 @@
80F6D05F28EE88FC00953C1A /* PBXTargetDependency */,
4AD9555D2C21716A00D0EEFA /* PBXTargetDependency */,
3F0FDA032D9B930100CD05D6 /* PBXTargetDependency */,
+ 942520892FB33FC00027445A /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
0C3C988F2DA04EEF009F3BFB /* Jetpack */,
@@ -2538,7 +2696,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
- LastSwiftUpdateCheck = 1640;
+ LastSwiftUpdateCheck = 2650;
LastUpgradeCheck = 2600;
ORGANIZATIONNAME = WordPress;
TargetAttributes = {
@@ -2636,6 +2794,17 @@
};
};
};
+ 942520692FB33FBE0027445A = {
+ CreatedOnToolsVersion = 26.5;
+ };
+ 942520752FB33FC00027445A = {
+ CreatedOnToolsVersion = 26.5;
+ TestTargetID = 942520692FB33FBE0027445A;
+ };
+ 9425207F2FB33FC00027445A = {
+ CreatedOnToolsVersion = 26.5;
+ TestTargetID = 942520692FB33FBE0027445A;
+ };
E16AB92914D978240047A2E5 = {
DevelopmentTeam = PZYM8XX95Q;
LastSwiftMigration = 1000;
@@ -2721,6 +2890,9 @@
0C3313B62E0439A8000C3760 /* Miniature */,
0C3313C22E0439A9000C3760 /* MiniatureTests */,
4A8280FC2E5FE9B60037E180 /* WordPressKitTests */,
+ 942520692FB33FBE0027445A /* JetpackWatch Watch App */,
+ 942520752FB33FC00027445A /* JetpackWatch Watch AppTests */,
+ 9425207F2FB33FC00027445A /* JetpackWatch Watch AppUITests */,
);
};
/* End PBXProject section */
@@ -2892,6 +3064,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 +3641,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 +3821,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 +6771,440 @@
};
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;
+ INFOPLIST_FILE = "JetpackWatch Watch App/Info.plist";
+ 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;
+ INFOPLIST_FILE = "JetpackWatch Watch App/Info.plist";
+ 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;
+ INFOPLIST_FILE = "JetpackWatch Watch App/Info.plist";
+ 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 +7910,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 = (