improve(ios): manage notifications from Privacy (#102733)

* improve(ios): manage notifications from privacy

Co-authored-by: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com>

* fix(ios): make notification status returns explicit

---------

Co-authored-by: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com>
This commit is contained in:
Peter Steinberger 2026-07-09 12:35:29 +01:00 committed by GitHub
parent 981d67a703
commit 6621ead871
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 562 additions and 367 deletions

File diff suppressed because it is too large Load diff

View file

@ -37,6 +37,8 @@ struct SettingsProTab: View {
@AppStorage("gateway.onboardingComplete") var onboardingComplete: Bool = false
@AppStorage("gateway.hasConnectedOnce") var hasConnectedOnce: Bool = false
@AppStorage("onboarding.requestID") var onboardingRequestID: Int = 0
@AppStorage(NotificationServingPreference.storageKey) var notificationServingEnabled: Bool =
NotificationServingPreference.defaultEnabled
@State var isReconnectingGateway = false
@State var isRefreshingGateway = false
@State var isChangingLocationMode = false
@ -262,7 +264,7 @@ struct SettingsProTab: View {
.sheet(isPresented: self.$showNotificationRelayDisclosure) {
HostedPushRelayDisclosureSheet(
message: self.notificationRelayDisclosureMessage,
onContinue: self.requestNotificationAuthorizationFromSettings)
onContinue: self.acceptNotificationRelayDisclosure)
}
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button(role: .destructive) {

View file

@ -62,7 +62,7 @@ extension SettingsProTab {
title: "Notifications",
detail: "Approval and event alert channel",
value: self.notificationStatusText,
color: self.notificationStatus.color)
color: self.notificationStatusColor)
self.diagnosticCheckRow(
icon: "rectangle.on.rectangle",
title: "Screen Capture",
@ -183,7 +183,7 @@ extension SettingsProTab {
gatewayConnected: self.gatewayDiagnosticConnected,
discoveredGatewayCount: self.gatewayController.gateways.count,
talkConfigLoaded: self.gatewayDiagnosticTalkConfigLoaded,
notificationsAllowed: self.notificationStatus == .allowed)
notificationsAllowed: self.notificationServingActive)
self.diagnosticsIssueCount = issueCount
self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date())
}
@ -611,18 +611,58 @@ extension SettingsProTab {
}
}
func handleNotificationAction() {
if self.notificationStatus.shouldOpenNotificationSettings {
self.openNotificationSettings()
func handleNotificationServingToggleChange(_ isOn: Bool) {
guard isOn else {
self.notificationServingEnabled = false
// UIKit stops APNs delivery here; re-enabling registers again and the
// app delegate republishes the current token to the active gateway.
UIApplication.shared.unregisterForRemoteNotifications()
return
}
guard self.notificationStatus == .notSet else { return }
if PushBuildConfig.current.usesOpenClawHostedRelay {
self.showNotificationRelayDisclosure = true
return
switch self.notificationStatus {
case .allowed:
self.enableNotificationServing()
case .notSet:
guard self.prepareNotificationEnrollment() else { return }
self.requestNotificationAuthorizationFromSettings()
case .notAllowed, .unknown:
self.notificationServingEnabled = true
self.openNotificationSettings()
case .checking:
break
}
}
private func prepareNotificationEnrollment() -> Bool {
if PushBuildConfig.current.usesOpenClawHostedRelay,
!PushEnrollmentConsent.disclosureAccepted
{
self.showNotificationRelayDisclosure = true
return false
}
return true
}
private func enableNotificationServing() {
guard self.prepareNotificationEnrollment() else { return }
self.notificationServingEnabled = true
self.registerForRemoteNotificationsIfEnrollmentReady()
}
func acceptNotificationRelayDisclosure() {
PushEnrollmentConsent.markDisclosureAccepted()
switch self.notificationStatus {
case .allowed:
self.enableNotificationServing()
case .notSet:
self.requestNotificationAuthorizationFromSettings()
case .notAllowed, .unknown:
self.notificationServingEnabled = true
self.openNotificationSettings()
case .checking:
self.notificationServingEnabled = false
}
self.requestNotificationAuthorizationFromSettings()
}
func requestNotificationAuthorizationFromSettings() {
@ -639,7 +679,8 @@ extension SettingsProTab {
await MainActor.run {
self.isRequestingNotificationAuthorization = false
self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus)
guard granted else { return }
self.notificationServingEnabled = granted && self.notificationStatus.allowsNotifications
guard self.notificationServingEnabled else { return }
self.registerForRemoteNotificationsIfEnrollmentReady()
}
}
@ -647,7 +688,10 @@ extension SettingsProTab {
@MainActor
func registerForRemoteNotificationsIfEnrollmentReady() {
guard PushEnrollmentConsent.disclosureAccepted else { return }
guard self.notificationServingEnabled else { return }
guard !PushBuildConfig.current.usesOpenClawHostedRelay
|| PushEnrollmentConsent.disclosureAccepted
else { return }
guard self.notificationStatus.allowsNotifications else { return }
UIApplication.shared.registerForRemoteNotifications()
}
@ -1019,12 +1063,7 @@ extension SettingsProTab {
}
var notificationsNeedAttention: Bool {
switch self.notificationStatus {
case .allowed, .checking:
false
case .notAllowed, .notSet, .unknown:
true
}
self.notificationPresentation.needsAttention
}
var approvalItems: [SettingsApprovalItem] {
@ -1105,26 +1144,51 @@ extension SettingsProTab {
}
var notificationStatusText: String {
self.notificationStatus.text
self.notificationPresentation.text
}
var notificationActionText: String {
self.notificationStatus.actionTitle
var notificationStatusColor: Color {
self.notificationPresentation.color
}
var notificationServingActive: Bool {
self.notificationPresentation.isActive
}
var notificationDisclosureAccepted: Bool {
!PushBuildConfig.current.usesOpenClawHostedRelay
|| PushEnrollmentConsent.disclosureAccepted
}
var notificationToggleBinding: Binding<Bool> {
Binding(
get: { self.notificationServingActive },
set: { self.handleNotificationServingToggleChange($0) })
}
var notificationPresentation: SettingsNotificationPresentation {
switch self.notificationStatus {
case .checking:
return .checking
case .allowed:
if !self.notificationServingEnabled {
return .off
}
if !self.notificationDisclosureAccepted {
return .setup
}
return .enabled
case .notAllowed:
return .denied
case .notSet:
return .notSet
case .unknown:
return .unknown
}
}
var notificationStatusDetail: String {
switch self.notificationStatus {
case .checking:
"Checking iOS notification permission."
case .allowed:
"OpenClaw can show approval prompts and event alerts when the app is not active."
case .notAllowed:
"Notifications have been denied. Enable them in iOS Settings."
case .notSet:
"Enable notifications to receive approval prompts and event alerts outside the app."
case .unknown:
"OpenClaw cannot determine the current notification permission state."
}
self.notificationPresentation.detail
}
var notificationRelayDetail: String {

View file

@ -173,11 +173,6 @@ extension SettingsProTab {
iconColor: .indigo,
title: "Privacy",
route: .privacy)
self.settingsListRow(
icon: "bell.fill",
iconColor: .red,
title: "Notifications",
route: .notifications)
self.settingsListRow(
icon: "info.circle.fill",
iconColor: .gray,
@ -500,6 +495,8 @@ extension SettingsProTab {
var privacyDestination: some View {
Group {
self.notificationsSection
self.detailStatusCard(
icon: "hand.raised",
title: "Privacy",
@ -522,49 +519,42 @@ extension SettingsProTab {
}
var notificationsDestination: some View {
Group {
self.detailStatusCard(
icon: "bell",
title: "Notifications",
detail: self.notificationStatusDetail,
value: self.notificationStatusText,
color: self.notificationStatus.color)
Section {
VStack(alignment: .leading, spacing: 12) {
Button {
self.handleNotificationAction()
} label: {
Label(
self.notificationActionText,
systemImage: self.notificationStatus.actionIcon)
.font(OpenClawType.captionSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(self.notificationStatus == .checking || self.isRequestingNotificationAuthorization)
self.notificationsSection
}
var notificationsSection: some View {
Section("Notifications") {
HStack(spacing: 12) {
SettingsIcon(systemName: "bell.fill", color: self.notificationStatusColor)
VStack(alignment: .leading, spacing: 2) {
Text("Notifications")
.font(OpenClawType.subheadSemiBold)
Text(self.notificationStatusDetail)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Divider()
HStack(alignment: .top, spacing: 10) {
Image(systemName: "network")
.font(OpenClawType.captionSemiBold)
.foregroundStyle(OpenClawBrand.accent)
.frame(width: 22, height: 22)
Text(self.notificationRelayDetail)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer(minLength: 8)
Toggle("Notifications", isOn: self.notificationToggleBinding)
.labelsHidden()
.disabled(self.notificationStatus == .checking || self.isRequestingNotificationAuthorization)
.accessibilityIdentifier("settings-notifications-toggle")
.accessibilityValue(self.notificationServingActive ? "On" : "Off")
.accessibilityHint("Turns OpenClaw notification delivery on or off")
}
HStack(alignment: .top, spacing: 10) {
Image(systemName: "network")
.font(OpenClawType.captionSemiBold)
.foregroundStyle(OpenClawBrand.accent)
.frame(width: 22, height: 22)
Text(self.notificationRelayDetail)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.accessibilityIdentifier("settings-privacy-notifications-section")
}
var gatewayActions: some View {

View file

@ -112,64 +112,68 @@ enum SettingsNotificationStatus: Equatable {
}
}
var allowsNotifications: Bool {
self == .allowed
}
}
enum SettingsNotificationPresentation: Equatable {
case checking
case enabled
case off
case setup
case denied
case notSet
case unknown
var text: String {
switch self {
case .checking: "Checking"
case .allowed: "Enabled"
case .notAllowed: "Denied"
case .enabled: "Enabled"
case .off: "Off"
case .setup: "Setup"
case .denied: "Denied"
case .notSet: "Not Enabled"
case .unknown: "Unknown"
}
}
var actionTitle: String {
var detail: String {
switch self {
case .notSet:
"Enable Notifications"
case .checking:
"Checking"
case .allowed:
"Manage in iOS Settings"
case .notAllowed, .unknown:
"Open iOS Settings"
}
}
var actionIcon: String {
switch self {
case .allowed:
"gear"
case .notAllowed, .unknown:
"gear.badge"
case .checking:
"hourglass"
"Checking iOS notification permission."
case .enabled:
"OpenClaw can show approval prompts and event alerts when the app is not active."
case .off:
"OpenClaw notifications are off."
case .setup:
"Finish notification setup to receive alerts when the app is not active."
case .denied:
"Notifications have been denied. Enable them in iOS Settings."
case .notSet:
"bell.badge"
"Enable notifications to receive approval prompts and event alerts outside the app."
case .unknown:
"OpenClaw cannot determine the current notification permission state."
}
}
var color: Color {
switch self {
case .allowed:
case .enabled:
OpenClawBrand.ok
case .notAllowed, .unknown:
case .denied, .setup, .unknown:
OpenClawBrand.warn
case .checking, .notSet:
case .checking, .notSet, .off:
.secondary
}
}
var shouldOpenNotificationSettings: Bool {
switch self {
case .allowed, .notAllowed, .unknown:
true
case .checking, .notSet:
false
}
var isActive: Bool {
self == .enabled
}
var allowsNotifications: Bool {
self == .allowed
var needsAttention: Bool {
self != .checking && self != .enabled
}
}

View file

@ -1708,7 +1708,7 @@ final class NodeAppModel {
}
let status = await notificationAuthorizationStatus()
guard Self.isNotificationAuthorizationAllowed(status) else {
guard Self.isNotificationServingEnabled(status) else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
@ -1762,7 +1762,7 @@ final class NodeAppModel {
let shouldSpeak = params.speak ?? true
let status = await notificationAuthorizationStatus()
let notificationsAllowed = Self.isNotificationAuthorizationAllowed(status)
let notificationsAllowed = Self.isNotificationServingEnabled(status)
if !notificationsAllowed, !shouldSpeak {
return BridgeInvokeResponse(
id: req.id,
@ -1827,6 +1827,12 @@ final class NodeAppModel {
}
}
private static func isNotificationServingEnabled(
_ status: NotificationAuthorizationStatus) -> Bool
{
NotificationServingPreference.isEnabled() && self.isNotificationAuthorizationAllowed(status)
}
private func presentNotificationPermissionGuidanceForExecApprovalIfNeeded(
approvalId: String,
shouldApply: @MainActor @Sendable () -> Bool = { true }) async
@ -5852,9 +5858,13 @@ extension NodeAppModel {
}
private func canPublishAPNsRegistration(usesRelayTransport: Bool) async -> Bool {
guard PushEnrollmentConsent.disclosureAccepted else {
if usesRelayTransport, !PushEnrollmentConsent.disclosureAccepted {
GatewayDiagnostics.pushRelay.skipped("enrollment_disclosure_not_accepted")
return false
}
guard NotificationServingPreference.isEnabled() else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("enrollment_disclosure_not_accepted")
GatewayDiagnostics.pushRelay.skipped("notification_serving_disabled")
}
return false
}

View file

@ -169,7 +169,10 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
private func registerForRemoteNotificationsIfEnrollmentReady(_ application: UIApplication) async {
guard PushEnrollmentConsent.disclosureAccepted else { return }
guard NotificationServingPreference.isEnabled() else { return }
guard !PushBuildConfig.current.usesOpenClawHostedRelay
|| PushEnrollmentConsent.disclosureAccepted
else { return }
guard await Self.isNotificationAuthorizationAllowed() else { return }
application.registerForRemoteNotifications()
}
@ -580,6 +583,7 @@ enum WatchPromptNotificationBridge {
}
private static func isNotificationAuthorizationAllowed() async -> Bool {
guard NotificationServingPreference.isEnabled() else { return false }
let center = UNUserNotificationCenter.current()
let status = await notificationAuthorizationStatus(center: center)
return self.isAuthorizationStatusAllowed(status)

View file

@ -14,6 +14,18 @@ enum NotificationAuthorizationStatus {
case ephemeral
}
enum NotificationServingPreference {
static let storageKey = "notifications.serving.enabled"
static let defaultEnabled = true
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
guard defaults.object(forKey: self.storageKey) != nil else {
return self.defaultEnabled
}
return defaults.bool(forKey: self.storageKey)
}
}
protocol NotificationCentering: Sendable {
func authorizationStatus() async -> NotificationAuthorizationStatus
func add(_ request: UNNotificationRequest) async throws

View file

@ -348,6 +348,19 @@ private actor WatchSnapshotSendGate {
}
}
private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Void {
let defaults = UserDefaults.standard
let previous = defaults.object(forKey: NotificationServingPreference.storageKey)
defaults.set(enabled, forKey: NotificationServingPreference.storageKey)
return {
if let previous {
defaults.set(previous, forKey: NotificationServingPreference.storageKey)
} else {
defaults.removeObject(forKey: NotificationServingPreference.storageKey)
}
}
}
@Suite(.serialized) struct NodeAppModelInvokeTests {
@Test @MainActor func `decode params fails without JSON`() {
#expect(throws: Error.self) {
@ -2229,6 +2242,8 @@ private actor WatchSnapshotSendGate {
}
@Test @MainActor func `system notify schedules when notifications are already allowed`() async throws {
let restorePreference = overrideNotificationServingPreference(true)
defer { restorePreference() }
let center = MockBootstrapNotificationCenter()
center.status = .authorized
let appModel = NodeAppModel(notificationCenter: center)
@ -2245,7 +2260,30 @@ private actor WatchSnapshotSendGate {
#expect(center.addCalls == 1)
}
@Test @MainActor func `apns registration requires disclosure and notification authorization`() async {
@Test @MainActor func `system notify respects app notification opt out`() async throws {
let restorePreference = overrideNotificationServingPreference(false)
defer { restorePreference() }
let center = MockBootstrapNotificationCenter()
center.status = .authorized
let appModel = NodeAppModel(notificationCenter: center)
let params = OpenClawSystemNotifyParams(title: "Approval", body: "Review request")
let paramsData = try JSONEncoder().encode(params)
let req = BridgeInvokeRequest(
id: "notify-disabled",
command: OpenClawSystemCommand.notify.rawValue,
paramsJSON: String(decoding: paramsData, as: UTF8.self))
let res = await appModel._test_handleInvoke(req)
#expect(res.ok == false)
#expect(res.error?.code == .unavailable)
#expect(res.error?.message == "NOT_AUTHORIZED: notifications")
#expect(center.addCalls == 0)
}
@Test @MainActor func `apns registration requires notification authorization and relay disclosure`() async {
let restorePreference = overrideNotificationServingPreference(true)
defer { restorePreference() }
let center = MockBootstrapNotificationCenter()
center.status = .authorized
let appModel = NodeAppModel(notificationCenter: center)
@ -2253,7 +2291,7 @@ private actor WatchSnapshotSendGate {
defer { PushEnrollmentConsent.reset() }
#expect(await appModel._test_canPublishAPNsRegistration() == false)
#expect(await appModel._test_canPublishAPNsRegistration(usesRelayTransport: false) == false)
#expect(await appModel._test_canPublishAPNsRegistration(usesRelayTransport: false))
PushEnrollmentConsent.markDisclosureAccepted()
center.status = .notDetermined
@ -2261,6 +2299,9 @@ private actor WatchSnapshotSendGate {
center.status = .authorized
#expect(await appModel._test_canPublishAPNsRegistration())
UserDefaults.standard.set(false, forKey: NotificationServingPreference.storageKey)
#expect(await appModel._test_canPublishAPNsRegistration() == false)
}
@Test @MainActor func `chat push without speech returns unavailable when notifications off`() async throws {
@ -2283,6 +2324,8 @@ private actor WatchSnapshotSendGate {
}
@Test @MainActor func `chat push schedules when notifications are already allowed`() async throws {
let restorePreference = overrideNotificationServingPreference(true)
defer { restorePreference() }
let center = MockBootstrapNotificationCenter()
center.status = .authorized
let appModel = NodeAppModel(notificationCenter: center)

View file

@ -0,0 +1,29 @@
import Foundation
import Testing
@testable import OpenClaw
struct NotificationServingPreferenceTests {
@Test func `defaults to enabled`() throws {
let (suiteName, defaults) = try self.makeDefaults()
defer { defaults.removePersistentDomain(forName: suiteName) }
#expect(NotificationServingPreference.isEnabled(defaults: defaults))
}
@Test func `persists explicit opt out and opt in`() throws {
let (suiteName, defaults) = try self.makeDefaults()
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(false, forKey: NotificationServingPreference.storageKey)
#expect(!NotificationServingPreference.isEnabled(defaults: defaults))
defaults.set(true, forKey: NotificationServingPreference.storageKey)
#expect(NotificationServingPreference.isEnabled(defaults: defaults))
}
private func makeDefaults() throws -> (String, UserDefaults) {
let suiteName = "NotificationServingPreferenceTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
return (suiteName, defaults)
}
}

View file

@ -759,6 +759,7 @@ struct RootTabsSourceGuardTests {
let modelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
#expect(appSource.contains("PushEnrollmentConsent.disclosureAccepted"))
#expect(appSource.contains("NotificationServingPreference.isEnabled()"))
#expect(appSource.contains("await Self.isNotificationAuthorizationAllowed()"))
#expect(actionsSource.contains("PushEnrollmentConsent.markDisclosureAccepted()"))
#expect(actionsSource.contains("self.registerForRemoteNotificationsIfEnrollmentReady()"))
@ -767,6 +768,26 @@ struct RootTabsSourceGuardTests {
#expect(modelSource.contains("enrollment_disclosure_not_accepted"))
}
@Test func `notification preference lives in privacy and keeps system authority separate`() throws {
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
let settingsList = try Self.extract(
sectionsSource,
from: "@ViewBuilder var settingsListSection: some View",
to: "func settingsListRow(")
let privacyDestination = try Self.extract(
sectionsSource,
from: "var privacyDestination: some View",
to: "var notificationsDestination: some View")
#expect(!settingsList.contains("route: .notifications"))
#expect(privacyDestination.contains("self.notificationsSection"))
#expect(sectionsSource.contains("Toggle(\"Notifications\", isOn: self.notificationToggleBinding)"))
#expect(actionsSource.contains("UIApplication.shared.unregisterForRemoteNotifications()"))
#expect(actionsSource.contains("UIApplication.openNotificationSettingsURLString"))
#expect(!actionsSource.contains("UIApplication.openSettingsURLString"))
}
@Test func `gateway settings keeps pairing trust diagnostics and tailscale actions`() throws {
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)