import Foundation /// `type` of a push (`docs/api/native-companion-contract.md`). Unknown wire /// values map to a nil ``PushPayload/type`` — the message still renders from /// `title`/`body` (forward compatibility), just without type-specific actions. public enum PushType: String, Sendable { case ready case permissionRequest = "permission-request" case taskNotification = "task-notification" } /// `severity` — visual urgency accent (`hub/src/fcm/fcmService.ts`). public enum PushSeverity: String, Sendable { case info case success case warning case error } /// Parsed `notifySummary` (JSON string, only on `ready`): the agent's /// trailing `AGENT_NOTIFY_SUMMARY {...}` line, pre-truncated by the hub. /// All fields optional — the shape is agent-authored. public struct PushNotifySummary: Equatable, Sendable { public var version: Int? public var summary: String? public var action: String? public var status: String? public var agent: String? public var project: String? public init( version: Int? = nil, summary: String? = nil, action: String? = nil, status: String? = nil, agent: String? = nil, project: String? = nil ) { self.version = version self.summary = summary self.action = action self.status = status self.agent = agent self.project = project } } /// One decoded push message from a hub — the iOS mirror of the Android /// `PushPayload` (`android/core/data/.../push/PushPayload.kt`). The wire /// contract is the FCM data payload of `docs/api/native-companion-contract.md`; /// on iOS the same JSON object travels end-to-end encrypted inside /// ``PushEnvelope`` and every value arrives as a string. /// /// Parsing is deliberately tolerant: only `sessionId` is required (it anchors /// coalescing and tap-through navigation). Unknown `type`, `severity`, or /// `contractVersion` values never drop the message — it degrades to a plain /// title/body notification. public struct PushPayload: Equatable, Sendable { /// Decoded type, or nil when ``rawType`` is unknown to this client. public var type: PushType? /// The wire `type` string as received (diagnostics, category fallback). public var rawType: String public var sessionId: String public var sessionName: String? /// Hub-relative deep-link path, e.g. `/sessions/{id}` (informational). public var url: String? public var title: String? public var body: String? /// Permission requests only: the id for approve/deny. public var requestId: String? public var severity: PushSeverity? public var contractVersion: String? public var notifySummary: PushNotifySummary? /// The contract version this client implements. public static let contractVersion = "1" /// False when the hub stamped a `contractVersion` this client does not /// know. Per the contract's versioning rule, breaking changes bump the /// version — so an unknown version renders title/body only (no actions /// whose semantics may have changed). public var isKnownContractVersion: Bool { contractVersion == nil || contractVersion == Self.contractVersion } /// Whether type-specific affordances (Allow/Deny, Reply) may be attached. /// Requires a known contract version, a known type, and — for permission /// requests — a `requestId` to act on. public var supportsActions: Bool { guard isKnownContractVersion else { return false } switch type { case .permissionRequest: return requestId != nil case .ready, .taskNotification: return true case nil: return false } } /// `UNNotificationCategory` identifier (the contract type string) when /// actions apply, nil for a plain tap-to-open notification. The app /// registers categories under exactly these identifiers and the /// Notification Service Extension stamps the same value. public var categoryIdentifier: String? { supportsActions ? rawType : nil } /// Title to render; falls back to the session name, then a constant. public var displayTitle: String { if let title, !title.isPushBlank { return title } if let sessionName, !sessionName.isPushBlank { return sessionName } return "HAPI" } /// Body to render. For `ready` pushes carrying a parsed `notifySummary`, /// the summary (plus a `-> action` second line, when distinct) wins over /// the hub-composed `body` — Android parity. public var displayBody: String { if type == .ready, let summary = notifySummary?.summary, !summary.isPushBlank { if let action = notifySummary?.action, !action.isPushBlank, action != summary { return "\(summary)\n-> \(action)" } return summary } return body ?? "" } public init( type: PushType?, rawType: String, sessionId: String, sessionName: String? = nil, url: String? = nil, title: String? = nil, body: String? = nil, requestId: String? = nil, severity: PushSeverity? = nil, contractVersion: String? = nil, notifySummary: PushNotifySummary? = nil ) { self.type = type self.rawType = rawType self.sessionId = sessionId self.sessionName = sessionName self.url = url self.title = title self.body = body self.requestId = requestId self.severity = severity self.contractVersion = contractVersion self.notifySummary = notifySummary } // MARK: - Parsing /// Decodes a `[String: String]` field map — the decrypted-and-flattened /// form the Notification Service Extension stores into `userInfo` (and /// the exact shape of the Android `RemoteMessage.data` map). Returns nil /// only when `sessionId` is missing/blank — without it neither coalescing /// nor tap-through can work, and the contract guarantees it. public static func parse(dictionary data: [String: String]) -> PushPayload? { guard let sessionId = nonBlank(data["sessionId"]) else { return nil } let rawType = data["type"] ?? "" return PushPayload( type: PushType(rawValue: rawType), rawType: rawType, sessionId: sessionId, sessionName: nonBlank(data["sessionName"]), url: nonBlank(data["url"]), title: data["title"], body: data["body"], requestId: nonBlank(data["requestId"]), severity: data["severity"].flatMap(PushSeverity.init(rawValue:)), contractVersion: nonBlank(data["contractVersion"]), notifySummary: data["notifySummary"].flatMap(parseNotifySummary) ) } /// Decodes decrypted ``PushEnvelope`` plaintext: a JSON object whose /// values are strings per the FCM data contract. Non-string scalars from /// a future hub are tolerated by stringifying; nested values and a /// non-object root are rejected the same as a missing `sessionId`. public static func parse(plaintext: Data) -> PushPayload? { guard let object = try? JSONSerialization.jsonObject(with: plaintext), let fields = object as? [String: Any] else { return nil } var map: [String: String] = [:] for (key, value) in fields { switch value { case let string as String: map[key] = string case let number as NSNumber: map[key] = number.stringValue default: continue // nested/null values carry no contract meaning here } } return parse(dictionary: map) } /// Malformed JSON → nil (the hub-composed `body` remains the fallback). private static func parseNotifySummary(_ raw: String) -> PushNotifySummary? { guard let object = try? JSONSerialization.jsonObject(with: Data(raw.utf8)), let fields = object as? [String: Any] else { return nil } func string(_ key: String) -> String? { fields[key] as? String } return PushNotifySummary( version: (fields["version"] as? NSNumber)?.intValue, summary: string("summary"), action: string("action"), status: string("status"), agent: string("agent"), project: string("project") ) } /// Android-parity blankness: whitespace-only counts as absent, but the /// value is returned untrimmed. private static func nonBlank(_ value: String?) -> String? { guard let value, !value.isPushBlank else { return nil } return value } } extension String { /// Kotlin `isBlank()` equivalent for the parity checks above. fileprivate var isPushBlank: Bool { trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } }