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 = (