mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
981d67a703
commit
6621ead871
11 changed files with 562 additions and 367 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
29
apps/ios/Tests/NotificationServingPreferenceTests.swift
Normal file
29
apps/ios/Tests/NotificationServingPreferenceTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue