From 90a5e8ba9dd1b02d1335c1ab5b5c9a533d98ebb1 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:51:26 -1000 Subject: [PATCH] fix: Rate limit iOS peer notifications to prevent flood (#972) * fix: Rate limit iOS peer notifications to prevent flood - Remove aggressive formIntersection that cleared recentlySeenPeers when peers temporarily dropped, causing them to be treated as "new" - Add 5-minute cooldown between notifications (aligns with Android) - Use fixed notification identifier so iOS updates existing notification instead of creating new ones - Only mark peers as seen when notification is sent, so peers arriving during cooldown are included in the next notification Fixes notification spam every 10-30 seconds when peers fluctuate. Co-Authored-By: Claude Opus 4.5 * chore: Bump version to 1.5.1 --------- Co-authored-by: jack Co-authored-by: Claude Opus 4.5 --- bitchat.xcodeproj/project.pbxproj | 8 +++---- bitchat/Services/NotificationService.swift | 3 ++- bitchat/Services/TransportConfig.swift | 1 + bitchat/ViewModels/ChatViewModel.swift | 27 ++++++++++++++-------- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 9b526671..572035df 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -558,7 +558,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "$(MARKETING_VERSION)"; + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -618,7 +618,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "$(MARKETING_VERSION)"; + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -654,7 +654,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = "$(MARKETING_VERSION)"; + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -746,7 +746,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = "$(MARKETING_VERSION)"; + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index e55f7777..636bd97d 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -96,7 +96,8 @@ final class NotificationService { func sendNetworkAvailableNotification(peerCount: Int) { let title = "👥 bitchatters nearby!" let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around" - let identifier = "network-available-\(Date().timeIntervalSince1970)" + // Fixed identifier so iOS updates the existing notification instead of creating new ones + let identifier = "network-available" sendLocalNotification( title: title, diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 986bc22f..5265af59 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -21,6 +21,7 @@ enum TransportConfig { // Timers static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes + static let networkNotificationCooldownSeconds: TimeInterval = 300 // 5 minutes static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching // BLE duty/announce/connect diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index c0f4488a..f54bc647 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -3348,18 +3348,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv self.scheduleNetworkEmptyTimer() } else { self.invalidateNetworkEmptyTimer() - // Trim out peers we no longer observe before comparing for new arrivals - self.recentlySeenPeers.formIntersection(meshPeerSet) + // Don't trim recentlySeenPeers here - let timers handle cleanup. + // Trimming immediately causes peers to be treated as "new" when they + // briefly drop and reconnect, triggering notification floods. let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers) - + if !newPeers.isEmpty { - self.lastNetworkNotificationTime = Date() - self.recentlySeenPeers.formUnion(newPeers) - NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count) - SecureLogger.info( - "👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))", - category: .session - ) + // Rate limit: max one notification per 5 minutes + let cooldown = TransportConfig.networkNotificationCooldownSeconds + if Date().timeIntervalSince(self.lastNetworkNotificationTime) >= cooldown { + // Only mark peers as seen when we actually notify about them + // This ensures peers arriving during cooldown will be included in the next notification + self.recentlySeenPeers.formUnion(newPeers) + self.lastNetworkNotificationTime = Date() + NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count) + SecureLogger.info( + "👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))", + category: .session + ) + } self.scheduleNetworkResetTimer() } }