From 993dae61197856d702a81593331cb9ee93ae2a10 Mon Sep 17 00:00:00 2001 From: Alek Michelson Date: Thu, 9 Apr 2026 23:23:29 -0400 Subject: [PATCH 1/6] fix iOS notification polling completion and persistence plus better logs --- ntfy/App/AppDelegate.swift | 41 +++++++++++++++++++--- ntfy/Persistence/Store.swift | 38 ++++++++++---------- ntfy/Persistence/SubscriptionManager.swift | 6 ++-- ntfy/Utils/ApiService.swift | 2 +- ntfyNSE/NotificationService.swift | 9 +---- 5 files changed, 60 insertions(+), 36 deletions(-) diff --git a/ntfy/App/AppDelegate.swift b/ntfy/App/AppDelegate.swift index 3d237e3..9b8f270 100644 --- a/ntfy/App/AppDelegate.swift +++ b/ntfy/App/AppDelegate.swift @@ -52,14 +52,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { // Poll and show new messages as notifications let store = Store.shared let subscriptionManager = SubscriptionManager(store: store) - store.getSubscriptions()?.forEach { subscription in + let subscriptions = store.getSubscriptions() ?? [] + guard !subscriptions.isEmpty else { + completionHandler(.noData) + return + } + + let group = DispatchGroup() + let resultQueue = DispatchQueue(label: "io.heckel.ntfy.background-poll-result") + var didReceiveNewData = false + subscriptions.forEach { subscription in + group.enter() + guard let baseUrl = subscription.baseUrl else { + Log.w(tag, "Skipping background poll notification for subscription with missing baseUrl") + group.leave() + return + } subscriptionManager.poll(subscription) { messages in - messages.forEach { message in - self.showNotification(subscription, message) + if !messages.isEmpty { + resultQueue.sync { + didReceiveNewData = true + } } + messages.forEach { message in + self.showNotification(baseUrl: baseUrl, message) + } + group.leave() } } - completionHandler(.newData) + group.notify(queue: .main) { + completionHandler(didReceiveNewData ? .newData : .noData) + } } func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { @@ -76,8 +99,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { /// local notification look exactly like the remote one (same userInfo), so that when we tap it, the userNotificationCenter(didReceive) function /// has the same information available. private func showNotification(_ subscription: Subscription, _ message: Message) { + guard let baseUrl = subscription.baseUrl else { + Log.w(tag, "Skipping notification for subscription with missing baseUrl") + return + } + showNotification(baseUrl: baseUrl, message) + } + + private func showNotification(baseUrl: String, _ message: Message) { let content = UNMutableNotificationContent() - content.modify(message: message, baseUrl: subscription.baseUrl ?? "?") + content.modify(message: message, baseUrl: baseUrl) let request = UNNotificationRequest(identifier: message.id, content: content, trigger: nil /* now */) UNUserNotificationCenter.current().add(request) { (error) in diff --git a/ntfy/Persistence/Store.swift b/ntfy/Persistence/Store.swift index 93ba732..9cc2c90 100644 --- a/ntfy/Persistence/Store.swift +++ b/ntfy/Persistence/Store.swift @@ -115,24 +115,26 @@ class Store: ObservableObject { // MARK: Notifications func save(notificationFromMessage message: Message, withSubscription subscription: Subscription) { - do { - let notification = Notification(context: context) - notification.id = message.id - notification.time = message.time - notification.message = message.message ?? "" - notification.title = message.title ?? "" - notification.priority = (message.priority != nil && message.priority != 0) ? message.priority! : 3 - notification.tags = message.tags?.joined(separator: ",") ?? "" - notification.actions = Actions.shared.encode(message.actions) - notification.click = message.click ?? "" - notification.subscription = subscription - subscription.addToNotifications(notification) - subscription.lastNotificationId = message.id - Log.d(Store.tag, "Storing notification with ID \(notification.id ?? "")") - try context.save() - } catch let error { - Log.w(Store.tag, "Cannot store notification (fromMessage)", error) - rollbackAndRefresh() + context.performAndWait { + do { + let notification = Notification(context: context) + notification.id = message.id + notification.time = message.time + notification.message = message.message ?? "" + notification.title = message.title ?? "" + notification.priority = (message.priority != nil && message.priority != 0) ? message.priority! : 3 + notification.tags = message.tags?.joined(separator: ",") ?? "" + notification.actions = Actions.shared.encode(message.actions) + notification.click = message.click ?? "" + notification.subscription = subscription + subscription.addToNotifications(notification) + subscription.lastNotificationId = message.id + Log.d(Store.tag, "Storing notification with ID \(notification.id ?? "")") + try context.save() + } catch let error { + Log.w(Store.tag, "Cannot store notification (fromMessage)", error) + rollbackAndRefresh() + } } } diff --git a/ntfy/Persistence/SubscriptionManager.swift b/ntfy/Persistence/SubscriptionManager.swift index a4f2b96..bf5e71d 100644 --- a/ntfy/Persistence/SubscriptionManager.swift +++ b/ntfy/Persistence/SubscriptionManager.swift @@ -47,10 +47,8 @@ struct SubscriptionManager { } Log.d(tag, "Polling success, \(messages.count) new message(s)", messages) if !messages.isEmpty { - DispatchQueue.main.sync { - for message in messages { - store.save(notificationFromMessage: message, withSubscription: subscription) - } + for message in messages { + store.save(notificationFromMessage: message, withSubscription: subscription) } } completionHandler(messages) diff --git a/ntfy/Utils/ApiService.swift b/ntfy/Utils/ApiService.swift index 97aafca..2cb495d 100644 --- a/ntfy/Utils/ApiService.swift +++ b/ntfy/Utils/ApiService.swift @@ -8,7 +8,7 @@ class ApiService { func poll(subscription: Subscription, user: BasicUser?, completionHandler: @escaping ([Message]?, Error?) -> Void) { guard let url = URL(string: subscription.urlString()) else { - // FIXME + completionHandler(nil, URLError(.badURL)) return } let since = subscription.lastNotificationId ?? "all" diff --git a/ntfyNSE/NotificationService.swift b/ntfyNSE/NotificationService.swift index 4a4c77f..18d65d1 100644 --- a/ntfyNSE/NotificationService.swift +++ b/ntfyNSE/NotificationService.swift @@ -80,7 +80,7 @@ class NotificationService: UNNotificationServiceExtension { // Poll original server let user = store?.getUser(baseUrl: baseUrl)?.toBasicUser() - let semaphore = DispatchSemaphore(value: 0) + // The extension only needs contentHandler to be called from the async callback ApiService.shared.poll(subscription: subscription, messageId: pollId, user: user) { message, error in guard let message = message else { Log.w(self.tag, "Error fetching message", error) @@ -88,13 +88,6 @@ class NotificationService: UNNotificationServiceExtension { return } self.handleMessage(request, content, baseUrl, message, contentHandler) - semaphore.signal() } - - // Note: If notifications only show up as "New message", it may be because the "return" statement - // happens before the contentHandler() is called. We add this semaphore here to synchronize the threads. - // I don't know if this is necessary, but it feels like the right thing to do. - - _ = semaphore.wait(timeout: DispatchTime.now() + 25) // 30 seconds is the max for the entire extension } } From 354103fc177884c02ad94c360ec703559b967ac6 Mon Sep 17 00:00:00 2001 From: Alek Michelson Date: Thu, 9 Apr 2026 23:42:23 -0400 Subject: [PATCH 2/6] handle poll requests more gracefully --- ntfy/Persistence/Notification.swift | 4 ++-- ntfyNSE/NotificationService.swift | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/ntfy/Persistence/Notification.swift b/ntfy/Persistence/Notification.swift index 286182f..ae17326 100644 --- a/ntfy/Persistence/Notification.swift +++ b/ntfy/Persistence/Notification.swift @@ -101,11 +101,11 @@ struct Message: Decodable { let time = userInfo["time"] as? String, let event = userInfo["event"] as? String, let topic = userInfo["topic"] as? String, - let timeInt = Int64(time), - let message = userInfo["message"] as? String else { + let timeInt = Int64(time) else { Log.d(Store.tag, "Unknown or irrelevant message", userInfo) return nil } + let message = userInfo["message"] as? String let title = userInfo["title"] as? String let priority = Int16(userInfo["priority"] as? String ?? "3") ?? 3 let tags = (userInfo["tags"] as? String ?? "").components(separatedBy: ",") diff --git a/ntfyNSE/NotificationService.swift b/ntfyNSE/NotificationService.swift index 18d65d1..304e860 100644 --- a/ntfyNSE/NotificationService.swift +++ b/ntfyNSE/NotificationService.swift @@ -66,11 +66,15 @@ class NotificationService: UNNotificationServiceExtension { } private func handlePollRequest(_ request: UNNotificationRequest, _ content: UNMutableNotificationContent, _ pollRequest: Message, _ contentHandler: @escaping (UNNotificationContent) -> Void) { - let subscription = store?.getSubscriptions()?.first { $0.urlHash() == pollRequest.topic } + let subscription = store?.getSubscriptions()?.first { subscription in + // Poll requests usually target the hashed topic URL, but tolerate raw topic payloads too + // Previously polls may have been ignored? + subscription.urlHash() == pollRequest.topic || subscription.topic == pollRequest.topic + } let baseUrl = subscription?.baseUrl + let pollId = pollRequest.pollId ?? pollRequest.id guard let subscription = subscription, - let pollId = pollRequest.pollId, let baseUrl = baseUrl else { Log.w(tag, "Cannot find subscription", pollRequest) From e46d9831112571a1ff4802e87701fe22d6082e31 Mon Sep 17 00:00:00 2001 From: Alek Michelson Date: Thu, 9 Apr 2026 23:48:10 -0400 Subject: [PATCH 3/6] more error handling --- ntfy/App/AppDelegate.swift | 18 +++++++++-- ntfy/Persistence/SubscriptionManager.swift | 18 +++++++++-- ntfy/Utils/ApiService.swift | 37 ++++++++++++++++++---- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/ntfy/App/AppDelegate.swift b/ntfy/App/AppDelegate.swift index 9b8f270..7d23681 100644 --- a/ntfy/App/AppDelegate.swift +++ b/ntfy/App/AppDelegate.swift @@ -169,15 +169,27 @@ extension AppDelegate: MessagingDelegate { Log.d(tag, "Firebase token received: \(String(describing: fcmToken))") // Subscribe to ~poll topic - Messaging.messaging().subscribe(toTopic: pollTopic) + Messaging.messaging().subscribe(toTopic: pollTopic) { error in + if let error { + Log.e(self.tag, "Firebase subscribe failed for \(self.pollTopic)", error) + } else { + Log.d(self.tag, "Firebase subscribe succeeded for \(self.pollTopic)") + } + } // Re-subscribe to Firebase for all topics let store = Store.shared - let subscriptionManager = SubscriptionManager(store: store) store.getSubscriptions()?.forEach{ subscription in if let baseUrl = subscription.baseUrl, let topic = subscription.topic { + let firebaseTopicName = firebaseTopic(baseUrl: baseUrl, topic: topic) Log.d(tag, "Re-subscribing to topic \(baseUrl)/\(topic)") - Messaging.messaging().subscribe(toTopic: firebaseTopic(baseUrl: baseUrl, topic: topic)) + Messaging.messaging().subscribe(toTopic: firebaseTopicName) { error in + if let error { + Log.e(self.tag, "Firebase subscribe failed for \(firebaseTopicName)", error) + } else { + Log.d(self.tag, "Firebase subscribe succeeded for \(firebaseTopicName)") + } + } } } } diff --git a/ntfy/Persistence/SubscriptionManager.swift b/ntfy/Persistence/SubscriptionManager.swift index bf5e71d..8a1ab76 100644 --- a/ntfy/Persistence/SubscriptionManager.swift +++ b/ntfy/Persistence/SubscriptionManager.swift @@ -9,8 +9,15 @@ struct SubscriptionManager { func subscribe(baseUrl: String, topic: String) { let normalizedBaseUrl = normalizeBaseUrl(baseUrl) + let firebaseTopicName = firebaseTopic(baseUrl: normalizedBaseUrl, topic: topic) Log.d(tag, "Subscribing to \(topicUrl(baseUrl: normalizedBaseUrl, topic: topic))") - Messaging.messaging().subscribe(toTopic: firebaseTopic(baseUrl: normalizedBaseUrl, topic: topic)) + Messaging.messaging().subscribe(toTopic: firebaseTopicName) { error in + if let error { + Log.e(tag, "Firebase subscribe failed for \(firebaseTopicName)", error) + } else { + Log.d(tag, "Firebase subscribe succeeded for \(firebaseTopicName)") + } + } let subscription = store.saveSubscription(baseUrl: normalizedBaseUrl, topic: topic) poll(subscription) } @@ -19,7 +26,14 @@ struct SubscriptionManager { Log.d(tag, "Unsubscribing from \(subscription.urlString())") DispatchQueue.main.async { if let baseUrl = subscription.baseUrl, let topic = subscription.topic { - Messaging.messaging().unsubscribe(fromTopic: firebaseTopic(baseUrl: baseUrl, topic: topic)) + let firebaseTopicName = firebaseTopic(baseUrl: baseUrl, topic: topic) + Messaging.messaging().unsubscribe(fromTopic: firebaseTopicName) { error in + if let error { + Log.e(tag, "Firebase unsubscribe failed for \(firebaseTopicName)", error) + } else { + Log.d(tag, "Firebase unsubscribe succeeded for \(firebaseTopicName)") + } + } } store.delete(subscription: subscription) } diff --git a/ntfy/Utils/ApiService.swift b/ntfy/Utils/ApiService.swift index 2cb495d..dade2bb 100644 --- a/ntfy/Utils/ApiService.swift +++ b/ntfy/Utils/ApiService.swift @@ -19,7 +19,10 @@ class ApiService { } func poll(subscription: Subscription, messageId: String, user: BasicUser?, completionHandler: @escaping (Message?, Error?) -> Void) { - let url = URL(string: "\(subscription.urlString())/json?poll=1&id=\(messageId)")! + guard let url = URL(string: "\(subscription.urlString())/json?poll=1&id=\(messageId)") else { + completionHandler(nil, URLError(.badURL)) + return + } Log.d(tag, "Polling single message from \(url) with user \(user?.username ?? "anonymous")") let request = newRequest(url: url, user: user) @@ -28,8 +31,16 @@ class ApiService { completionHandler(nil, error) return } + guard let httpResponse = response as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) else { + completionHandler(nil, URLError(.badServerResponse)) + return + } + guard let data = data else { + completionHandler(nil, URLError(.badServerResponse)) + return + } do { - let message = try JSONDecoder().decode(Message.self, from: data!) + let message = try JSONDecoder().decode(Message.self, from: data) completionHandler(message, nil) } catch { completionHandler(nil, error) @@ -98,19 +109,33 @@ class ApiService { } private func fetchJsonData(urlString: String, user: BasicUser?, completionHandler: @escaping ([T]?, Error?) -> ()) { - guard let url = URL(string: urlString) else { return } + guard let url = URL(string: urlString) else { + completionHandler(nil, URLError(.badURL)) + return + } let request = newRequest(url: url, user: user) newSession(timeout: 30).dataTask(with: request) { (data, response, error) in - if let error = error { + if let error { Log.e(self.tag, "Error fetching data", error) completionHandler(nil, error) return } + guard let httpResponse = response as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) else { + completionHandler(nil, URLError(.badServerResponse)) + return + } + guard let data = data else { + completionHandler(nil, URLError(.badServerResponse)) + return + } do { - let lines = String(decoding: data!, as: UTF8.self).split(whereSeparator: \.isNewline) + let lines = String(decoding: data, as: UTF8.self).split(whereSeparator: \.isNewline) var notifications: [T] = [] for jsonLine in lines { - notifications.append(try JSONDecoder().decode(T.self, from: jsonLine.data(using: .utf8)!)) + guard let jsonData = jsonLine.data(using: .utf8) else { + throw URLError(.cannotDecodeContentData) + } + notifications.append(try JSONDecoder().decode(T.self, from: jsonData)) } completionHandler(notifications, nil) } catch { From 94512211d88df0be9fb13fa562f6fec558985bbe Mon Sep 17 00:00:00 2001 From: Alek Michelson Date: Thu, 9 Apr 2026 23:50:36 -0400 Subject: [PATCH 4/6] more logging --- ntfy/App/AppDelegate.swift | 8 ++++++-- ntfyNSE/NotificationService.swift | 5 ++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/ntfy/App/AppDelegate.swift b/ntfy/App/AppDelegate.swift index 7d23681..3d7c480 100644 --- a/ntfy/App/AppDelegate.swift +++ b/ntfy/App/AppDelegate.swift @@ -88,7 +88,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let token = deviceToken.map { data in String(format: "%02.2hhx", data) }.joined() Messaging.messaging().apnsToken = deviceToken - Log.d(tag, "Registered for remote notifications. Passing APNs token to Firebase: \(token)") + Log.d(tag, "Registered for remote notifications. Passing APNs token \(token.prefix(12))... to Firebase") } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { @@ -166,7 +166,11 @@ extension AppDelegate: UNUserNotificationCenterDelegate { extension AppDelegate: MessagingDelegate { func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { - Log.d(tag, "Firebase token received: \(String(describing: fcmToken))") + if let fcmToken = fcmToken, !fcmToken.isEmpty { + Log.d(tag, "Firebase token received: \(fcmToken.prefix(12))...") + } else { + Log.w(tag, "Firebase token missing") + } // Subscribe to ~poll topic Messaging.messaging().subscribe(toTopic: pollTopic) { error in diff --git a/ntfyNSE/NotificationService.swift b/ntfyNSE/NotificationService.swift index 304e860..da93598 100644 --- a/ntfyNSE/NotificationService.swift +++ b/ntfyNSE/NotificationService.swift @@ -68,7 +68,6 @@ class NotificationService: UNNotificationServiceExtension { private func handlePollRequest(_ request: UNNotificationRequest, _ content: UNMutableNotificationContent, _ pollRequest: Message, _ contentHandler: @escaping (UNNotificationContent) -> Void) { let subscription = store?.getSubscriptions()?.first { subscription in // Poll requests usually target the hashed topic URL, but tolerate raw topic payloads too - // Previously polls may have been ignored? subscription.urlHash() == pollRequest.topic || subscription.topic == pollRequest.topic } let baseUrl = subscription?.baseUrl @@ -77,7 +76,7 @@ class NotificationService: UNNotificationServiceExtension { let subscription = subscription, let baseUrl = baseUrl else { - Log.w(tag, "Cannot find subscription", pollRequest) + Log.w(tag, "Cannot find subscription for poll request topic=\(pollRequest.topic), pollId=\(pollRequest.pollId ?? "")") contentHandler(request.content) return } @@ -87,7 +86,7 @@ class NotificationService: UNNotificationServiceExtension { // The extension only needs contentHandler to be called from the async callback ApiService.shared.poll(subscription: subscription, messageId: pollId, user: user) { message, error in guard let message = message else { - Log.w(self.tag, "Error fetching message", error) + Log.w(self.tag, "Error fetching poll request message topic=\(pollRequest.topic), pollId=\(pollId), subscription=\(subscription.urlString())", error) contentHandler(request.content) return } From 049f1943045966fff2dda5be11ea3c777cbc4d9d Mon Sep 17 00:00:00 2001 From: Alek Michelson Date: Fri, 10 Apr 2026 00:42:08 -0400 Subject: [PATCH 5/6] forgot to batch --- ntfy/Persistence/Store.swift | 36 +++++++++++++--------- ntfy/Persistence/SubscriptionManager.swift | 4 +-- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/ntfy/Persistence/Store.swift b/ntfy/Persistence/Store.swift index 9cc2c90..d8bfbd0 100644 --- a/ntfy/Persistence/Store.swift +++ b/ntfy/Persistence/Store.swift @@ -115,24 +115,32 @@ class Store: ObservableObject { // MARK: Notifications func save(notificationFromMessage message: Message, withSubscription subscription: Subscription) { + save(notificationsFromMessages: [message], withSubscription: subscription) + } + + func save(notificationsFromMessages messages: [Message], withSubscription subscription: Subscription) { + guard !messages.isEmpty else { return } + context.performAndWait { do { - let notification = Notification(context: context) - notification.id = message.id - notification.time = message.time - notification.message = message.message ?? "" - notification.title = message.title ?? "" - notification.priority = (message.priority != nil && message.priority != 0) ? message.priority! : 3 - notification.tags = message.tags?.joined(separator: ",") ?? "" - notification.actions = Actions.shared.encode(message.actions) - notification.click = message.click ?? "" - notification.subscription = subscription - subscription.addToNotifications(notification) - subscription.lastNotificationId = message.id - Log.d(Store.tag, "Storing notification with ID \(notification.id ?? "")") + for message in messages { + let notification = Notification(context: context) + notification.id = message.id + notification.time = message.time + notification.message = message.message ?? "" + notification.title = message.title ?? "" + notification.priority = (message.priority != nil && message.priority != 0) ? message.priority! : 3 + notification.tags = message.tags?.joined(separator: ",") ?? "" + notification.actions = Actions.shared.encode(message.actions) + notification.click = message.click ?? "" + notification.subscription = subscription + subscription.addToNotifications(notification) + subscription.lastNotificationId = message.id + Log.d(Store.tag, "Storing notification with ID \(notification.id ?? "")") + } try context.save() } catch let error { - Log.w(Store.tag, "Cannot store notification (fromMessage)", error) + Log.w(Store.tag, "Cannot store notifications (fromMessages)", error) rollbackAndRefresh() } } diff --git a/ntfy/Persistence/SubscriptionManager.swift b/ntfy/Persistence/SubscriptionManager.swift index 8a1ab76..dfd9e70 100644 --- a/ntfy/Persistence/SubscriptionManager.swift +++ b/ntfy/Persistence/SubscriptionManager.swift @@ -61,9 +61,7 @@ struct SubscriptionManager { } Log.d(tag, "Polling success, \(messages.count) new message(s)", messages) if !messages.isEmpty { - for message in messages { - store.save(notificationFromMessage: message, withSubscription: subscription) - } + store.save(notificationsFromMessages: messages, withSubscription: subscription) } completionHandler(messages) } From 0a968eb1913837596342c775fd07592c16b4f410 Mon Sep 17 00:00:00 2001 From: Alek Michelson Date: Fri, 10 Apr 2026 00:50:10 -0400 Subject: [PATCH 6/6] poll subscriptions more reactively --- ntfy/Views/SubscriptionListView.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/ntfy/Views/SubscriptionListView.swift b/ntfy/Views/SubscriptionListView.swift index c948c86..10881af 100644 --- a/ntfy/Views/SubscriptionListView.swift +++ b/ntfy/Views/SubscriptionListView.swift @@ -19,18 +19,14 @@ struct SubscriptionListView: View { if #available(iOS 15.0, *) { subscriptionList .refreshable { - subscriptionsModel.subscriptions.forEach { subscription in - subscriptionManager.poll(subscription) - } + pollSubscriptions() } } else { subscriptionList .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { - subscriptionsModel.subscriptions.forEach { subscription in - subscriptionManager.poll(subscription) - } + pollSubscriptions() } label: { Image(systemName: "arrow.clockwise") } @@ -81,6 +77,16 @@ struct SubscriptionListView: View { .sheet(isPresented: $showingAddDialog) { SubscriptionAddView(isShowing: $showingAddDialog) } + .onAppear { + // Ensures subscription count stays up to date, so a pull to refresh isn't required + pollSubscriptions() + } + } + + private func pollSubscriptions() { + subscriptionsModel.subscriptions.forEach { subscription in + subscriptionManager.poll(subscription) + } } }