Repository navigation
iOS: add native alerts, badges, and replay reconciliation #216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import Foundation | ||
| import UserNotifications | ||
| import CompanionCore | ||
|
|
||
| /// The on-device notification surface. Delivery comes from live or replayed | ||
| /// companion frames; a future APNs relay can feed the same categories and | ||
| /// userInfo without changing the rest of the app. | ||
| final class NotificationCoordinator: NSObject, UNUserNotificationCenterDelegate { | ||
| static let shared = NotificationCoordinator() | ||
| private let center = UNUserNotificationCenter.current() | ||
|
|
||
| private override init() { | ||
| super.init() | ||
| center.delegate = self | ||
| } | ||
|
|
||
| func authorizationStatus() async -> UNAuthorizationStatus { | ||
| await center.notificationSettings().authorizationStatus | ||
| } | ||
|
|
||
| func requestAuthorization() async -> Bool { | ||
| (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) == true | ||
| } | ||
|
|
||
| func deliver(_ notification: NotificationFrame, sequence: Int?) { | ||
| let content = UNMutableNotificationContent() | ||
| content.title = notification.title | ||
| content.body = notification.body | ||
| content.sound = .default | ||
| content.categoryIdentifier = notification.isBlocking ? "OPENMAUS_APPROVAL" : "OPENMAUS_UPDATE" | ||
| content.threadIdentifier = notification.threadId | ||
| content.userInfo = [ | ||
| "threadId": notification.threadId, | ||
| "botId": notification.botId, | ||
| "kind": notification.kind, | ||
| ] | ||
| if notification.isBlocking { content.interruptionLevel = .timeSensitive } | ||
|
|
||
| // A replay after a short disconnect must reconcile a missed alert, | ||
| // but a repeated frame must not draw it twice. | ||
| let identifier = "openmaus.\(notification.threadId).\(sequence.map(String.init) ?? notification.title)" | ||
| center.add(UNNotificationRequest(identifier: identifier, content: content, trigger: nil)) | ||
|
Comment on lines
+41
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
# Inspect existing replay and notification-request deduplication behavior.
rg -n -C 4 --glob '*.swift' \
'NotificationCoordinator|openmaus\.|deliveredNotifications|pendingNotificationRequests|deliver\(' iosRepository: milind-soni/OpenMausBot Length of output: 5823 🌐 Web query:
💡 Result: When you add a UNNotificationRequest to the UNUserNotificationCenter with an identifier that matches an existing notification, the system behavior depends on the state of that existing notification [1][2]: 1. If the identifier matches a pending notification request, the new request replaces the pending one [1][2]. 2. If the identifier matches a notification that has already been delivered and is still in the Notification Center, the system replaces the old notification with the new one [1][2]. In this case, the system will alert the user again and place the new notification at the top of the list [1][2]. In short, using the same identifier is the standard, system-supported way to update or replace a notification—whether it is still pending or already visible to the user [3][4][2]. If your goal is to avoid duplicates, you should use the same identifier for updates; if your goal is to have multiple distinct notifications appear, you must ensure each UNNotificationRequest uses a unique identifier [1][5][6]. Citations:
🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- Notifications.swift ---'
cat -n ios/App/Notifications.swift
printf '%s\n' '--- Session replay and sequence handling ---'
sed -n '210,265p' ios/App/Session.swift
rg -n -C 5 --glob '*.swift' 'advance\\(|lastSeq|seq|replay|resume|cursor|NotificationFrame' ios/AppRepository: milind-soni/OpenMausBot Length of output: 5851 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- cursor and frame state ---'
rg -n -C 6 --glob '*.swift' \
'advance\(|resetCursor|cursor|events\(since:|struct .*Frame|enum .*Frame|NotificationFrame' \
ios App Packages Sources 2>/dev/null || true
printf '%s\n' '--- notification-related tests ---'
rg -n -C 4 --glob '*Tests*' --glob '*.swift' \
'NotificationCoordinator|NotificationFrame|deliver\(|events\(since:' . 2>/dev/null || trueRepository: milind-soni/OpenMausBot Length of output: 50379 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re
notifications = Path("ios/App/Notifications.swift").read_text()
session = Path("ios/App/Session.swift").read_text()
store = Path("ios/Sources/CompanionCore/Store.swift").read_text()
identifier = re.search(
r'let identifier = "([^"]+)"',
notifications,
)
assert identifier, "NotificationCoordinator.deliver has no request identifier"
assert "sequence.map(String.init)" in identifier.group(1)
deliver_block = notifications[
notifications.index("func deliver"):
notifications.index("\n func setBadge")
]
assert "center.add(UNNotificationRequest" in deliver_block
assert not re.search(r'(delivered|pending|scheduled|seen|dedup)', deliver_block, re.I)
assert "NotificationCoordinator.shared.deliver(notification, sequence: frame.seq)" in session
assert session.index(
"NotificationCoordinator.shared.deliver(notification, sequence: frame.seq)"
) < session.index("state.advance(to: frame.seq)")
assert "frame in flight when the socket dropped arrives" in store
notify_append = re.search(
r'public mutating func apply\(.*?(?=\n public mutating func|\n}\Z)',
store,
re.S,
)
assert notify_append is not None
print("same sequence -> same request identifier")
print("replayed notify frames reach deliver()")
print("no local notification deduplication exists")
PYRepository: milind-soni/OpenMausBot Length of output: 284 Track delivered notification identifiers before scheduling replayed frames. Replayed 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| func setBadge(_ count: Int) { | ||
| center.setBadgeCount(max(0, count)) | ||
| } | ||
|
|
||
| func userNotificationCenter( | ||
| _ center: UNUserNotificationCenter, | ||
| willPresent notification: UNNotification, | ||
| withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void | ||
| ) { | ||
| completionHandler([.banner, .list, .sound, .badge]) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,6 +11,8 @@ import Foundation | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import OSLog | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import SwiftUI | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import CompanionCore | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import UserNotifications | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import UIKit | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Stream lifecycle, in Console.app and the Xcode console. A companion that | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// is silently not connected looks exactly like one with nothing to say, so | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -35,6 +37,7 @@ final class Session: ObservableObject { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Published var actionError: String? | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// One exact message the next opened chat should reveal. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Published private(set) var focusedMessageId: String? | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Published private(set) var notificationAuthorization: UNAuthorizationStatus = .notDetermined | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private var client: CompanionClient? | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private var streamTask: Task<Void, Never>? | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -56,7 +59,9 @@ final class Session: ObservableObject { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // MARK: - Pairing | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| init() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _ = NotificationCoordinator.shared | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| restore() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Task { await refreshNotificationAuthorization() } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Rebuild the last connection at launch. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -128,6 +133,7 @@ final class Session: ObservableObject { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| connection = nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| client = nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| state = CompanionState() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| NotificationCoordinator.shared.setBadge(0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| status = .unpaired | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -237,6 +243,10 @@ final class Session: ObservableObject { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| state.apply(frame) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if case let .notify(notification) = frame.frame { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| NotificationCoordinator.shared.deliver(notification, sequence: frame.seq) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| NotificationCoordinator.shared.setBadge(state.unreadCount) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| state.advance(to: frame.seq) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // the stream ended without an error — the harness went away | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -270,6 +280,7 @@ final class Session: ObservableObject { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let fleet = try await client.fleet(messages: 50) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.info("hydrated \(fleet.bots.count, privacy: .public) bots, \(fleet.groups.count, privacy: .public) rooms") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| state.hydrate(fleet) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| NotificationCoordinator.shared.setBadge(state.unreadCount) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // MARK: - Actions | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -464,6 +475,33 @@ final class Session: ObservableObject { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func refreshNotificationAuthorization() async { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| notificationAuthorization = await NotificationCoordinator.shared.authorizationStatus() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func enableNotifications() async { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if notificationAuthorization == .denied { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if let url = URL(string: UIApplication.openSettingsURLString) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await UIApplication.shared.open(url) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _ = await NotificationCoordinator.shared.requestAuthorization() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await refreshNotificationAuthorization() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| NotificationCoordinator.shared.setBadge(state.unreadCount) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+478
to
+491
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Synchronize the badge after authorization changes. When a denied user enables notifications in iOS Settings, Update the badge in Proposed fix func refreshNotificationAuthorization() async {
notificationAuthorization = await NotificationCoordinator.shared.authorizationStatus()
+ NotificationCoordinator.shared.setBadge(state.unreadCount)
}
...
_ = await NotificationCoordinator.shared.requestAuthorization()
await refreshNotificationAuthorization()
- NotificationCoordinator.shared.setBadge(state.unreadCount)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var notificationStatusText: String { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| switch notificationAuthorization { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case .authorized: return "On" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case .provisional: return "Quietly on" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case .ephemeral: return "Temporarily on" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case .denied: return "Off in Settings" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case .notDetermined: return "Not enabled" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @unknown default: return "Unknown" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private func perform(quietly: Bool = false, _ body: (CompanionClient) async throws -> Void) async { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| guard let client else { return } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| do { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Clarify when reconciliation occurs.
ios/App/Session.swift:215-276cancels the stream during backgrounding, andios/README.md:165-168describes the connection as foreground-only. Replace “background reconciliation” with “reconciliation when the app becomes active” so this text does not imply background execution or closed-app delivery.Proposed wording
📝 Committable suggestion
🤖 Prompt for AI Agents