iOS Watch: bridge mirrored notification actions into quick replies (#22123)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 401fbe8a7a
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-02-20 19:04:58 +00:00
committed by GitHub
parent 9476dda9f6
commit f52476f18c
3 changed files with 348 additions and 5 deletions

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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<Bool, Never>?
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<Void, Error>) 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