From f52476f18c378ba0ee80380e91014acbdd4145fe Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:04:58 +0000 Subject: [PATCH] iOS Watch: bridge mirrored notification actions into quick replies (#22123) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 401fbe8a7a6665fd0b8966c44312099df596cda8 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + apps/ios/Sources/Model/NodeAppModel.swift | 15 + apps/ios/Sources/OpenClawApp.swift | 337 +++++++++++++++++++++- 3 files changed, 348 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ef36834c9..9fd7187bb04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin. - iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky. +- iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky. - iOS/Tests: cover IPv4-mapped IPv6 loopback in manual TLS policy tests for connect validation paths. (#22045) Thanks @mbelinky. - iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky. - Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant. diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index e86fb75629c..06cb5453fc9 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1617,6 +1617,15 @@ private extension NodeAppModel { let result = try await self.watchMessagingService.sendNotification( id: req.id, params: params) + if result.queuedForDelivery || !result.deliveredImmediately { + let invokeID = req.id + Task { @MainActor in + await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded( + invokeID: invokeID, + params: params, + sendResult: result) + } + } let payload = OpenClawWatchNotifyPayload( deliveredImmediately: result.deliveredImmediately, queuedForDelivery: result.queuedForDelivery, @@ -2550,6 +2559,12 @@ extension NodeAppModel { } } +extension NodeAppModel { + func _bridgeConsumeMirroredWatchReply(_ event: WatchQuickReplyEvent) async { + await self.handleWatchQuickReply(event) + } +} + #if DEBUG extension NodeAppModel { func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index ade0cadad3b..335e09fd986 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -1,21 +1,48 @@ import SwiftUI import Foundation +import OpenClawKit import os import UIKit import BackgroundTasks +import UserNotifications -final class OpenClawAppDelegate: NSObject, UIApplicationDelegate { +private struct PendingWatchPromptAction { + var promptId: String? + var actionId: String + var actionLabel: String? + var sessionKey: String? +} + +@MainActor +final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate { private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push") private let backgroundWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "BackgroundWake") private static let wakeRefreshTaskIdentifier = "ai.openclaw.ios.bgrefresh" private var backgroundWakeTask: Task? private var pendingAPNsDeviceToken: Data? + private var pendingWatchPromptActions: [PendingWatchPromptAction] = [] + weak var appModel: NodeAppModel? { didSet { - guard let model = self.appModel, let token = self.pendingAPNsDeviceToken else { return } - self.pendingAPNsDeviceToken = nil - Task { @MainActor in - model.updateAPNsDeviceToken(token) + guard let model = self.appModel else { return } + if let token = self.pendingAPNsDeviceToken { + self.pendingAPNsDeviceToken = nil + Task { @MainActor in + model.updateAPNsDeviceToken(token) + } + } + if !self.pendingWatchPromptActions.isEmpty { + let pending = self.pendingWatchPromptActions + self.pendingWatchPromptActions.removeAll() + Task { @MainActor in + for action in pending { + await model.handleMirroredWatchPromptAction( + promptId: action.promptId, + actionId: action.actionId, + actionLabel: action.actionLabel, + sessionKey: action.sessionKey) + } + } } } } @@ -26,6 +53,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate { ) -> Bool { self.registerBackgroundWakeRefreshTask() + UNUserNotificationCenter.current().delegate = self application.registerForRemoteNotifications() return true } @@ -118,6 +146,305 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate { "Background wake refresh finished applied=\(applied, privacy: .public)") } } + + private static func isWatchPromptNotification(_ userInfo: [AnyHashable: Any]) -> Bool { + (userInfo[WatchPromptNotificationBridge.typeKey] as? String) == WatchPromptNotificationBridge.typeValue + } + + private static func parseWatchPromptAction( + from response: UNNotificationResponse) -> PendingWatchPromptAction? + { + let userInfo = response.notification.request.content.userInfo + guard Self.isWatchPromptNotification(userInfo) else { return nil } + + let promptId = userInfo[WatchPromptNotificationBridge.promptIDKey] as? String + let sessionKey = userInfo[WatchPromptNotificationBridge.sessionKeyKey] as? String + + switch response.actionIdentifier { + case WatchPromptNotificationBridge.actionPrimaryIdentifier: + let actionId = (userInfo[WatchPromptNotificationBridge.actionPrimaryIDKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !actionId.isEmpty else { return nil } + let actionLabel = userInfo[WatchPromptNotificationBridge.actionPrimaryLabelKey] as? String + return PendingWatchPromptAction( + promptId: promptId, + actionId: actionId, + actionLabel: actionLabel, + sessionKey: sessionKey) + case WatchPromptNotificationBridge.actionSecondaryIdentifier: + let actionId = (userInfo[WatchPromptNotificationBridge.actionSecondaryIDKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !actionId.isEmpty else { return nil } + let actionLabel = userInfo[WatchPromptNotificationBridge.actionSecondaryLabelKey] as? String + return PendingWatchPromptAction( + promptId: promptId, + actionId: actionId, + actionLabel: actionLabel, + sessionKey: sessionKey) + default: + return nil + } + } + + private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async { + guard let appModel = self.appModel else { + self.pendingWatchPromptActions.append(action) + return + } + await appModel.handleMirroredWatchPromptAction( + promptId: action.promptId, + actionId: action.actionId, + actionLabel: action.actionLabel, + sessionKey: action.sessionKey) + _ = await appModel.handleBackgroundRefreshWake(trigger: "watch_prompt_action") + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) + { + let userInfo = notification.request.content.userInfo + if Self.isWatchPromptNotification(userInfo) { + completionHandler([.banner, .list, .sound]) + return + } + completionHandler([]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) + { + guard let action = Self.parseWatchPromptAction(from: response) else { + completionHandler() + return + } + Task { @MainActor [weak self] in + guard let self else { + completionHandler() + return + } + await self.routeWatchPromptAction(action) + completionHandler() + } + } +} + +enum WatchPromptNotificationBridge { + static let typeKey = "openclaw.type" + static let typeValue = "watch.prompt" + static let promptIDKey = "openclaw.watch.promptId" + static let sessionKeyKey = "openclaw.watch.sessionKey" + static let actionPrimaryIDKey = "openclaw.watch.action.primary.id" + static let actionPrimaryLabelKey = "openclaw.watch.action.primary.label" + static let actionSecondaryIDKey = "openclaw.watch.action.secondary.id" + static let actionSecondaryLabelKey = "openclaw.watch.action.secondary.label" + static let actionPrimaryIdentifier = "openclaw.watch.action.primary" + static let actionSecondaryIdentifier = "openclaw.watch.action.secondary" + static let categoryPrefix = "openclaw.watch.prompt.category." + + @MainActor + static func scheduleMirroredWatchPromptNotificationIfNeeded( + invokeID: String, + params: OpenClawWatchNotifyParams, + sendResult: WatchNotificationSendResult) async + { + guard sendResult.queuedForDelivery || !sendResult.deliveredImmediately else { return } + + let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty || !body.isEmpty else { return } + guard await self.requestNotificationAuthorizationIfNeeded() else { return } + + let normalizedActions = (params.actions ?? []).compactMap { action -> OpenClawWatchAction? in + let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines) + let label = action.label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, !label.isEmpty else { return nil } + return OpenClawWatchAction(id: id, label: label, style: action.style) + } + let primaryAction = normalizedActions.first + let secondaryAction = normalizedActions.dropFirst().first + + let center = UNUserNotificationCenter.current() + var categoryIdentifier = "" + if let primaryAction { + let categoryID = "\(self.categoryPrefix)\(invokeID)" + let category = UNNotificationCategory( + identifier: categoryID, + actions: self.categoryActions(primaryAction: primaryAction, secondaryAction: secondaryAction), + intentIdentifiers: [], + options: []) + await self.upsertNotificationCategory(category, center: center) + categoryIdentifier = categoryID + } + + var userInfo: [AnyHashable: Any] = [ + self.typeKey: self.typeValue, + ] + if let promptId = params.promptId?.trimmingCharacters(in: .whitespacesAndNewlines), !promptId.isEmpty { + userInfo[self.promptIDKey] = promptId + } + if let sessionKey = params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty { + userInfo[self.sessionKeyKey] = sessionKey + } + if let primaryAction { + userInfo[self.actionPrimaryIDKey] = primaryAction.id + userInfo[self.actionPrimaryLabelKey] = primaryAction.label + } + if let secondaryAction { + userInfo[self.actionSecondaryIDKey] = secondaryAction.id + userInfo[self.actionSecondaryLabelKey] = secondaryAction.label + } + + let content = UNMutableNotificationContent() + content.title = title.isEmpty ? "OpenClaw" : title + content.body = body + content.sound = .default + content.userInfo = userInfo + if !categoryIdentifier.isEmpty { + content.categoryIdentifier = categoryIdentifier + } + if #available(iOS 15.0, *) { + switch params.priority ?? .active { + case .passive: + content.interruptionLevel = .passive + case .timeSensitive: + content.interruptionLevel = .timeSensitive + case .active: + content.interruptionLevel = .active + } + } + + let request = UNNotificationRequest( + identifier: "watch.prompt.\(invokeID)", + content: content, + trigger: nil) + try? await self.addNotificationRequest(request, center: center) + } + + private static func categoryActions( + primaryAction: OpenClawWatchAction, + secondaryAction: OpenClawWatchAction?) -> [UNNotificationAction] + { + var actions: [UNNotificationAction] = [ + UNNotificationAction( + identifier: self.actionPrimaryIdentifier, + title: primaryAction.label, + options: self.notificationActionOptions(style: primaryAction.style)) + ] + if let secondaryAction { + actions.append( + UNNotificationAction( + identifier: self.actionSecondaryIdentifier, + title: secondaryAction.label, + options: self.notificationActionOptions(style: secondaryAction.style))) + } + return actions + } + + private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions { + switch style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "destructive": + return [.destructive] + case "foreground": + // For mirrored watch actions, keep handling in background when possible. + return [] + default: + return [] + } + } + + private static func requestNotificationAuthorizationIfNeeded() async -> Bool { + let center = UNUserNotificationCenter.current() + let status = await self.notificationAuthorizationStatus(center: center) + switch status { + case .authorized, .provisional, .ephemeral: + return true + case .notDetermined: + let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false + if !granted { return false } + let updatedStatus = await self.notificationAuthorizationStatus(center: center) + return self.isAuthorizationStatusAllowed(updatedStatus) + case .denied: + return false + @unknown default: + return false + } + } + + private static func isAuthorizationStatusAllowed(_ status: UNAuthorizationStatus) -> Bool { + switch status { + case .authorized, .provisional, .ephemeral: + return true + case .denied, .notDetermined: + return false + @unknown default: + return false + } + } + + private static func notificationAuthorizationStatus(center: UNUserNotificationCenter) async -> UNAuthorizationStatus { + await withCheckedContinuation { continuation in + center.getNotificationSettings { settings in + continuation.resume(returning: settings.authorizationStatus) + } + } + } + + private static func upsertNotificationCategory( + _ category: UNNotificationCategory, + center: UNUserNotificationCenter) async + { + await withCheckedContinuation { continuation in + center.getNotificationCategories { categories in + var updated = categories + updated.update(with: category) + center.setNotificationCategories(updated) + continuation.resume() + } + } + } + + private static func addNotificationRequest(_ request: UNNotificationRequest, center: UNUserNotificationCenter) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + center.add(request) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + } +} + +extension NodeAppModel { + func handleMirroredWatchPromptAction( + promptId: String?, + actionId: String, + actionLabel: String?, + sessionKey: String?) async + { + let normalizedActionID = actionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedActionID.isEmpty else { return } + + let normalizedPromptID = promptId?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedSessionKey = sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedActionLabel = actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines) + + let event = WatchQuickReplyEvent( + replyId: UUID().uuidString, + promptId: (normalizedPromptID?.isEmpty == false) ? normalizedPromptID! : "unknown", + actionId: normalizedActionID, + actionLabel: (normalizedActionLabel?.isEmpty == false) ? normalizedActionLabel : nil, + sessionKey: (normalizedSessionKey?.isEmpty == false) ? normalizedSessionKey : nil, + note: "source=ios.notification", + sentAtMs: Int(Date().timeIntervalSince1970 * 1000), + transport: "ios.notification") + await self._bridgeConsumeMirroredWatchReply(event) + } } @main