Skip to content

Commit 6621ead

Browse files
improve(ios): manage notifications from Privacy (#102733)
* improve(ios): manage notifications from privacy Co-authored-by: Sahil Satralkar <[email protected]> * fix(ios): make notification status returns explicit --------- Co-authored-by: Sahil Satralkar <[email protected]>
1 parent 981d67a commit 6621ead

11 files changed

Lines changed: 525 additions & 330 deletions

apps/.i18n/native-source.json

Lines changed: 238 additions & 222 deletions
Large diffs are not rendered by default.

apps/ios/Sources/Design/SettingsProTab.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ struct SettingsProTab: View {
3737
@AppStorage("gateway.onboardingComplete") var onboardingComplete: Bool = false
3838
@AppStorage("gateway.hasConnectedOnce") var hasConnectedOnce: Bool = false
3939
@AppStorage("onboarding.requestID") var onboardingRequestID: Int = 0
40+
@AppStorage(NotificationServingPreference.storageKey) var notificationServingEnabled: Bool =
41+
NotificationServingPreference.defaultEnabled
4042
@State var isReconnectingGateway = false
4143
@State var isRefreshingGateway = false
4244
@State var isChangingLocationMode = false
@@ -262,7 +264,7 @@ struct SettingsProTab: View {
262264
.sheet(isPresented: self.$showNotificationRelayDisclosure) {
263265
HostedPushRelayDisclosureSheet(
264266
message: self.notificationRelayDisclosureMessage,
265-
onContinue: self.requestNotificationAuthorizationFromSettings)
267+
onContinue: self.acceptNotificationRelayDisclosure)
266268
}
267269
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
268270
Button(role: .destructive) {

apps/ios/Sources/Design/SettingsProTabActions.swift

Lines changed: 90 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ extension SettingsProTab {
6262
title: "Notifications",
6363
detail: "Approval and event alert channel",
6464
value: self.notificationStatusText,
65-
color: self.notificationStatus.color)
65+
color: self.notificationStatusColor)
6666
self.diagnosticCheckRow(
6767
icon: "rectangle.on.rectangle",
6868
title: "Screen Capture",
@@ -183,7 +183,7 @@ extension SettingsProTab {
183183
gatewayConnected: self.gatewayDiagnosticConnected,
184184
discoveredGatewayCount: self.gatewayController.gateways.count,
185185
talkConfigLoaded: self.gatewayDiagnosticTalkConfigLoaded,
186-
notificationsAllowed: self.notificationStatus == .allowed)
186+
notificationsAllowed: self.notificationServingActive)
187187
self.diagnosticsIssueCount = issueCount
188188
self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date())
189189
}
@@ -611,18 +611,58 @@ extension SettingsProTab {
611611
}
612612
}
613613

614-
func handleNotificationAction() {
615-
if self.notificationStatus.shouldOpenNotificationSettings {
616-
self.openNotificationSettings()
614+
func handleNotificationServingToggleChange(_ isOn: Bool) {
615+
guard isOn else {
616+
self.notificationServingEnabled = false
617+
// UIKit stops APNs delivery here; re-enabling registers again and the
618+
// app delegate republishes the current token to the active gateway.
619+
UIApplication.shared.unregisterForRemoteNotifications()
617620
return
618621
}
619-
guard self.notificationStatus == .notSet else { return }
620622

621-
if PushBuildConfig.current.usesOpenClawHostedRelay {
623+
switch self.notificationStatus {
624+
case .allowed:
625+
self.enableNotificationServing()
626+
case .notSet:
627+
guard self.prepareNotificationEnrollment() else { return }
628+
self.requestNotificationAuthorizationFromSettings()
629+
case .notAllowed, .unknown:
630+
self.notificationServingEnabled = true
631+
self.openNotificationSettings()
632+
case .checking:
633+
break
634+
}
635+
}
636+
637+
private func prepareNotificationEnrollment() -> Bool {
638+
if PushBuildConfig.current.usesOpenClawHostedRelay,
639+
!PushEnrollmentConsent.disclosureAccepted
640+
{
622641
self.showNotificationRelayDisclosure = true
623-
return
642+
return false
643+
}
644+
return true
645+
}
646+
647+
private func enableNotificationServing() {
648+
guard self.prepareNotificationEnrollment() else { return }
649+
self.notificationServingEnabled = true
650+
self.registerForRemoteNotificationsIfEnrollmentReady()
651+
}
652+
653+
func acceptNotificationRelayDisclosure() {
654+
PushEnrollmentConsent.markDisclosureAccepted()
655+
switch self.notificationStatus {
656+
case .allowed:
657+
self.enableNotificationServing()
658+
case .notSet:
659+
self.requestNotificationAuthorizationFromSettings()
660+
case .notAllowed, .unknown:
661+
self.notificationServingEnabled = true
662+
self.openNotificationSettings()
663+
case .checking:
664+
self.notificationServingEnabled = false
624665
}
625-
self.requestNotificationAuthorizationFromSettings()
626666
}
627667

628668
func requestNotificationAuthorizationFromSettings() {
@@ -639,15 +679,19 @@ extension SettingsProTab {
639679
await MainActor.run {
640680
self.isRequestingNotificationAuthorization = false
641681
self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus)
642-
guard granted else { return }
682+
self.notificationServingEnabled = granted && self.notificationStatus.allowsNotifications
683+
guard self.notificationServingEnabled else { return }
643684
self.registerForRemoteNotificationsIfEnrollmentReady()
644685
}
645686
}
646687
}
647688

648689
@MainActor
649690
func registerForRemoteNotificationsIfEnrollmentReady() {
650-
guard PushEnrollmentConsent.disclosureAccepted else { return }
691+
guard self.notificationServingEnabled else { return }
692+
guard !PushBuildConfig.current.usesOpenClawHostedRelay
693+
|| PushEnrollmentConsent.disclosureAccepted
694+
else { return }
651695
guard self.notificationStatus.allowsNotifications else { return }
652696
UIApplication.shared.registerForRemoteNotifications()
653697
}
@@ -1019,12 +1063,7 @@ extension SettingsProTab {
10191063
}
10201064

10211065
var notificationsNeedAttention: Bool {
1022-
switch self.notificationStatus {
1023-
case .allowed, .checking:
1024-
false
1025-
case .notAllowed, .notSet, .unknown:
1026-
true
1027-
}
1066+
self.notificationPresentation.needsAttention
10281067
}
10291068

10301069
var approvalItems: [SettingsApprovalItem] {
@@ -1105,28 +1144,53 @@ extension SettingsProTab {
11051144
}
11061145

11071146
var notificationStatusText: String {
1108-
self.notificationStatus.text
1147+
self.notificationPresentation.text
11091148
}
11101149

1111-
var notificationActionText: String {
1112-
self.notificationStatus.actionTitle
1150+
var notificationStatusColor: Color {
1151+
self.notificationPresentation.color
11131152
}
11141153

1115-
var notificationStatusDetail: String {
1154+
var notificationServingActive: Bool {
1155+
self.notificationPresentation.isActive
1156+
}
1157+
1158+
var notificationDisclosureAccepted: Bool {
1159+
!PushBuildConfig.current.usesOpenClawHostedRelay
1160+
|| PushEnrollmentConsent.disclosureAccepted
1161+
}
1162+
1163+
var notificationToggleBinding: Binding<Bool> {
1164+
Binding(
1165+
get: { self.notificationServingActive },
1166+
set: { self.handleNotificationServingToggleChange($0) })
1167+
}
1168+
1169+
var notificationPresentation: SettingsNotificationPresentation {
11161170
switch self.notificationStatus {
11171171
case .checking:
1118-
"Checking iOS notification permission."
1172+
return .checking
11191173
case .allowed:
1120-
"OpenClaw can show approval prompts and event alerts when the app is not active."
1174+
if !self.notificationServingEnabled {
1175+
return .off
1176+
}
1177+
if !self.notificationDisclosureAccepted {
1178+
return .setup
1179+
}
1180+
return .enabled
11211181
case .notAllowed:
1122-
"Notifications have been denied. Enable them in iOS Settings."
1182+
return .denied
11231183
case .notSet:
1124-
"Enable notifications to receive approval prompts and event alerts outside the app."
1184+
return .notSet
11251185
case .unknown:
1126-
"OpenClaw cannot determine the current notification permission state."
1186+
return .unknown
11271187
}
11281188
}
11291189

1190+
var notificationStatusDetail: String {
1191+
self.notificationPresentation.detail
1192+
}
1193+
11301194
var notificationRelayDetail: String {
11311195
if PushBuildConfig.current.usesOpenClawHostedRelay {
11321196
let host = PushBuildConfig.current.relayBaseURL.flatMap {

apps/ios/Sources/Design/SettingsProTabSections.swift

Lines changed: 30 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,6 @@ extension SettingsProTab {
173173
iconColor: .indigo,
174174
title: "Privacy",
175175
route: .privacy)
176-
self.settingsListRow(
177-
icon: "bell.fill",
178-
iconColor: .red,
179-
title: "Notifications",
180-
route: .notifications)
181176
self.settingsListRow(
182177
icon: "info.circle.fill",
183178
iconColor: .gray,
@@ -500,6 +495,8 @@ extension SettingsProTab {
500495

501496
var privacyDestination: some View {
502497
Group {
498+
self.notificationsSection
499+
503500
self.detailStatusCard(
504501
icon: "hand.raised",
505502
title: "Privacy",
@@ -522,49 +519,42 @@ extension SettingsProTab {
522519
}
523520

524521
var notificationsDestination: some View {
525-
Group {
526-
self.detailStatusCard(
527-
icon: "bell",
528-
title: "Notifications",
529-
detail: self.notificationStatusDetail,
530-
value: self.notificationStatusText,
531-
color: self.notificationStatus.color)
532-
533-
Section {
534-
VStack(alignment: .leading, spacing: 12) {
535-
Button {
536-
self.handleNotificationAction()
537-
} label: {
538-
Label(
539-
self.notificationActionText,
540-
systemImage: self.notificationStatus.actionIcon)
541-
.font(OpenClawType.captionSemiBold)
542-
.frame(maxWidth: .infinity)
543-
}
544-
.buttonStyle(.borderedProminent)
545-
.controlSize(.small)
546-
.disabled(self.notificationStatus == .checking || self.isRequestingNotificationAuthorization)
522+
self.notificationsSection
523+
}
547524

525+
var notificationsSection: some View {
526+
Section("Notifications") {
527+
HStack(spacing: 12) {
528+
SettingsIcon(systemName: "bell.fill", color: self.notificationStatusColor)
529+
VStack(alignment: .leading, spacing: 2) {
530+
Text("Notifications")
531+
.font(OpenClawType.subheadSemiBold)
548532
Text(self.notificationStatusDetail)
549533
.font(OpenClawType.caption)
550534
.foregroundStyle(.secondary)
551535
.fixedSize(horizontal: false, vertical: true)
552-
553-
Divider()
554-
555-
HStack(alignment: .top, spacing: 10) {
556-
Image(systemName: "network")
557-
.font(OpenClawType.captionSemiBold)
558-
.foregroundStyle(OpenClawBrand.accent)
559-
.frame(width: 22, height: 22)
560-
Text(self.notificationRelayDetail)
561-
.font(OpenClawType.caption)
562-
.foregroundStyle(.secondary)
563-
.fixedSize(horizontal: false, vertical: true)
564-
}
565536
}
537+
Spacer(minLength: 8)
538+
Toggle("Notifications", isOn: self.notificationToggleBinding)
539+
.labelsHidden()
540+
.disabled(self.notificationStatus == .checking || self.isRequestingNotificationAuthorization)
541+
.accessibilityIdentifier("settings-notifications-toggle")
542+
.accessibilityValue(self.notificationServingActive ? "On" : "Off")
543+
.accessibilityHint("Turns OpenClaw notification delivery on or off")
544+
}
545+
546+
HStack(alignment: .top, spacing: 10) {
547+
Image(systemName: "network")
548+
.font(OpenClawType.captionSemiBold)
549+
.foregroundStyle(OpenClawBrand.accent)
550+
.frame(width: 22, height: 22)
551+
Text(self.notificationRelayDetail)
552+
.font(OpenClawType.caption)
553+
.foregroundStyle(.secondary)
554+
.fixedSize(horizontal: false, vertical: true)
566555
}
567556
}
557+
.accessibilityIdentifier("settings-privacy-notifications-section")
568558
}
569559

570560
var gatewayActions: some View {

0 commit comments

Comments
 (0)