diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index a675c9af658..bcef133a774 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -152,6 +152,7 @@ extension SettingsProTab { } let notificationSettings = await UNUserNotificationCenter.current().notificationSettings() self.applyNotificationStatus(notificationSettings.authorizationStatus) + self.registerForRemoteNotificationsIfEnrollmentReady() let issueCount = SettingsDiagnostics.issueCount( gatewayConnected: self.gatewayDiagnosticConnected, @@ -417,6 +418,7 @@ extension SettingsProTab { let status = settings.authorizationStatus Task { @MainActor in self.applyNotificationStatus(status) + self.registerForRemoteNotificationsIfEnrollmentReady() } } } @@ -437,6 +439,7 @@ extension SettingsProTab { func requestNotificationAuthorizationFromSettings() { guard !self.isRequestingNotificationAuthorization else { return } + PushEnrollmentConsent.markDisclosureAccepted() self.isRequestingNotificationAuthorization = true Task { let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [ @@ -448,12 +451,19 @@ extension SettingsProTab { await MainActor.run { self.isRequestingNotificationAuthorization = false self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus) - guard granted, self.notificationStatus.allowsNotifications else { return } - UIApplication.shared.registerForRemoteNotifications() + guard granted else { return } + self.registerForRemoteNotificationsIfEnrollmentReady() } } } + @MainActor + func registerForRemoteNotificationsIfEnrollmentReady() { + guard PushEnrollmentConsent.disclosureAccepted else { return } + guard self.notificationStatus.allowsNotifications else { return } + UIApplication.shared.registerForRemoteNotifications() + } + @MainActor func applyNotificationStatus(_ status: UNAuthorizationStatus) { self.notificationStatus = SettingsNotificationStatus(status) diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 2ba4adb5f1c..889fffb02ab 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -4103,6 +4103,9 @@ extension NodeAppModel { private func registerAPNsTokenIfNeeded() async { let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport + guard await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport) else { + return + } guard self.gatewayConnected else { if usesRelayTransport { GatewayDiagnostics.pushRelay.skipped("gateway_offline") @@ -4163,6 +4166,23 @@ extension NodeAppModel { } } + private func canPublishAPNsRegistration(usesRelayTransport: Bool) async -> Bool { + guard PushEnrollmentConsent.disclosureAccepted else { + if usesRelayTransport { + GatewayDiagnostics.pushRelay.skipped("enrollment_disclosure_not_accepted") + } + return false + } + let status = await self.notificationAuthorizationStatus() + guard Self.isNotificationAuthorizationAllowed(status) else { + if usesRelayTransport { + GatewayDiagnostics.pushRelay.skipped("notifications_not_authorized") + } + return false + } + return true + } + private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity { let response = try await self.operatorGateway.request( method: "gateway.identity.get", @@ -5126,6 +5146,10 @@ extension NodeAppModel { self.setOperatorConnected(connected) } + func _test_canPublishAPNsRegistration(usesRelayTransport: Bool = true) async -> Bool { + await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport) + } + nonisolated static func _test_makeWatchChatItems(from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] { self.makeWatchChatItems(from: raw) } diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 11bb32bfecc..2dd4e36e626 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -123,10 +123,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc let notificationCenter = UNUserNotificationCenter.current() notificationCenter.delegate = self ExecApprovalNotificationBridge.registerCategory(center: notificationCenter) - application.registerForRemoteNotifications() + Task { @MainActor in + await self.registerForRemoteNotificationsIfEnrollmentReady(application) + } return true } + private func registerForRemoteNotificationsIfEnrollmentReady(_ application: UIApplication) async { + guard PushEnrollmentConsent.disclosureAccepted else { return } + guard await Self.isNotificationAuthorizationAllowed() else { return } + application.registerForRemoteNotifications() + } + + private static func isNotificationAuthorizationAllowed() async -> Bool { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + case .denied, .notDetermined: + return false + @unknown default: + return false + } + } + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { if let appModel = self.resolvedAppModel() { Task { @MainActor in diff --git a/apps/ios/Sources/Push/PushEnrollmentConsent.swift b/apps/ios/Sources/Push/PushEnrollmentConsent.swift new file mode 100644 index 00000000000..d22bd215cb5 --- /dev/null +++ b/apps/ios/Sources/Push/PushEnrollmentConsent.swift @@ -0,0 +1,19 @@ +import Foundation + +enum PushEnrollmentConsent { + static let disclosureAcceptedKey = "push.enrollment.disclosureAccepted" + + static var disclosureAccepted: Bool { + UserDefaults.standard.bool(forKey: disclosureAcceptedKey) + } + + static func markDisclosureAccepted() { + UserDefaults.standard.set(true, forKey: self.disclosureAcceptedKey) + } + + #if DEBUG + static func reset() { + UserDefaults.standard.removeObject(forKey: self.disclosureAcceptedKey) + } + #endif +} diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 66fa52db705..b26cb8440a4 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -76,6 +76,7 @@ Sources/Permissions/PermissionRequestBridge.swift Sources/Push/ExecApprovalNotificationBridge.swift Sources/Push/BackgroundAliveBeacon.swift Sources/Push/PushBuildConfig.swift +Sources/Push/PushEnrollmentConsent.swift Sources/Push/PushRegistrationManager.swift Sources/Push/PushRelayClient.swift Sources/Push/PushRelayKeychainStore.swift diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index b5e49648961..69127f890f8 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -1377,6 +1377,24 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(center.addCalls == 1) } + @Test @MainActor func apnsRegistrationRequiresDisclosureAndNotificationAuthorization() async { + let center = MockBootstrapNotificationCenter() + center.status = .authorized + let appModel = NodeAppModel(notificationCenter: center) + PushEnrollmentConsent.reset() + defer { PushEnrollmentConsent.reset() } + + #expect(await appModel._test_canPublishAPNsRegistration() == false) + #expect(await appModel._test_canPublishAPNsRegistration(usesRelayTransport: false) == false) + + PushEnrollmentConsent.markDisclosureAccepted() + center.status = .notDetermined + #expect(await appModel._test_canPublishAPNsRegistration() == false) + + center.status = .authorized + #expect(await appModel._test_canPublishAPNsRegistration()) + } + @Test @MainActor func chatPushWithoutSpeechReturnsUnavailableWhenNotificationsOff() async throws { let center = MockBootstrapNotificationCenter() center.status = .notDetermined diff --git a/apps/ios/Tests/RootTabsSourceGuardTests.swift b/apps/ios/Tests/RootTabsSourceGuardTests.swift index 3e7c55b4539..f4adb6fd28e 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests.swift @@ -550,6 +550,20 @@ struct RootTabsSourceGuardTests { #expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")")) } + @Test func `push enrollment stays behind notification disclosure flow`() throws { + let appSource = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8) + let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8) + let modelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8) + + #expect(appSource.contains("PushEnrollmentConsent.disclosureAccepted")) + #expect(appSource.contains("await Self.isNotificationAuthorizationAllowed()")) + #expect(actionsSource.contains("PushEnrollmentConsent.markDisclosureAccepted()")) + #expect(actionsSource.contains("self.registerForRemoteNotificationsIfEnrollmentReady()")) + #expect(modelSource.contains("PushEnrollmentConsent.disclosureAccepted")) + #expect(modelSource.contains("notifications_not_authorized")) + #expect(modelSource.contains("enrollment_disclosure_not_accepted")) + } + @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) @@ -786,6 +800,13 @@ struct RootTabsSourceGuardTests { .appendingPathComponent("Sources/Design/SettingsProTab.swift") } + private static func openClawAppSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/OpenClawApp.swift") + } + private static func notificationPermissionGuidanceDialogSourceURL() -> URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent()