diff --git a/Configs/Release.xcconfig b/Configs/Release.xcconfig index 988ce02d..e4b48f6e 100644 --- a/Configs/Release.xcconfig +++ b/Configs/Release.xcconfig @@ -1,4 +1,4 @@ -MARKETING_VERSION = 1.5.1 +MARKETING_VERSION = 1.5.2 CURRENT_PROJECT_VERSION = 1 IPHONEOS_DEPLOYMENT_TARGET = 16.0 diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index a648a09f..bfa922c5 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -321,7 +321,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1640; + LastUpgradeCheck = 2650; }; buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */; developmentRegion = en; @@ -446,7 +446,6 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; LD_RUNPATH_SEARCH_PATHS = ( @@ -471,7 +470,6 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; LD_RUNPATH_SEARCH_PATHS = ( @@ -498,7 +496,6 @@ CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -523,7 +520,6 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; @@ -556,7 +552,6 @@ CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = NO; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -566,7 +561,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.1; + MARKETING_VERSION = 1.5.2; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -590,7 +585,6 @@ CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -617,7 +611,6 @@ CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -627,7 +620,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.1; + MARKETING_VERSION = 1.5.2; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -653,7 +646,6 @@ CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -663,7 +655,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = 1.5.1; + MARKETING_VERSION = 1.5.2; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -676,6 +668,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -709,6 +702,7 @@ CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -726,6 +720,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = "$(SWIFT_VERSION)"; @@ -745,7 +740,6 @@ CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = NO; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -755,7 +749,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = 1.5.1; + MARKETING_VERSION = 1.5.2; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -768,6 +762,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -801,6 +796,7 @@ CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -825,6 +821,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = "$(SWIFT_VERSION)"; @@ -841,7 +838,6 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; diff --git a/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme b/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme index 8000e27d..234d9a1d 100644 --- a/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme +++ b/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme @@ -1,6 +1,6 @@ { private let candidateCap: Int private let weakLinkCooldownSeconds: TimeInterval private let weakLinkRSSICutoff: Int - private let recentTimeoutWindowSeconds: TimeInterval - private let recentTimeoutCountThreshold: Int private var lastGlobalConnectAttempt: Date = .distantPast private var candidates: [BLEConnectionCandidate] = [] private var failureCounts: [String: Int] = [:] private var recentConnectTimeouts: [String: Date] = [:] + // Tracked separately from connect timeouts: a peer we held a connection + // with and lost (walked out of range) usually comes back, so it only gets + // a brief rediscovery ignore — not the timeout backoff/cooldown treatment + // reserved for peers that never answered a connect attempt. + private var recentDisconnects: [String: Date] = [:] private var lastIsolatedAt: Date? private let initialDynamicRSSIThreshold: Int @@ -63,8 +66,6 @@ final class BLEConnectionScheduler { candidateCap: Int = TransportConfig.bleConnectionCandidatesMax, weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds, weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff, - recentTimeoutWindowSeconds: TimeInterval = TransportConfig.bleRecentTimeoutWindowSeconds, - recentTimeoutCountThreshold: Int = TransportConfig.bleRecentTimeoutCountThreshold, dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault ) { self.maxCentralLinks = maxCentralLinks @@ -72,8 +73,6 @@ final class BLEConnectionScheduler { self.candidateCap = candidateCap self.weakLinkCooldownSeconds = weakLinkCooldownSeconds self.weakLinkRSSICutoff = weakLinkRSSICutoff - self.recentTimeoutWindowSeconds = recentTimeoutWindowSeconds - self.recentTimeoutCountThreshold = recentTimeoutCountThreshold self.initialDynamicRSSIThreshold = dynamicRSSIThreshold self.dynamicRSSIThreshold = dynamicRSSIThreshold } @@ -114,7 +113,12 @@ final class BLEConnectionScheduler { } if let lastTimeout = recentConnectTimeouts[candidate.peripheralID], - now.timeIntervalSince(lastTimeout) < 15 { + now.timeIntervalSince(lastTimeout) < TransportConfig.bleTimeoutDiscoveryIgnoreSeconds { + return .ignore + } + + if let lastDisconnect = recentDisconnects[candidate.peripheralID], + now.timeIntervalSince(lastDisconnect) < TransportConfig.bleDisconnectDiscoveryIgnoreSeconds { return .ignore } @@ -163,6 +167,11 @@ final class BLEConnectionScheduler { return .retryAfter(delay) } + if let delay = disconnectSettleDelay(for: candidate, now: now) { + enqueue(candidate) + return .retryAfter(delay) + } + if isAlreadyConnectingOrConnected(candidate.peripheralID) { continue } @@ -180,6 +189,7 @@ final class BLEConnectionScheduler { func recordConnectionSuccess(peripheralID: String) { failureCounts[peripheralID] = 0 recentConnectTimeouts.removeValue(forKey: peripheralID) + recentDisconnects.removeValue(forKey: peripheralID) } func recordConnectionFailure(peripheralID: String) { @@ -187,7 +197,7 @@ final class BLEConnectionScheduler { } func recordDisconnectError(peripheralID: String, at now: Date) { - recentConnectTimeouts[peripheralID] = now + recentDisconnects[peripheralID] = now } func recordConnectionTimeout(peripheralID: String, at now: Date) { @@ -197,6 +207,7 @@ final class BLEConnectionScheduler { func pruneConnectionTimeouts(before cutoff: Date) { recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff } + recentDisconnects = recentDisconnects.filter { $0.value >= cutoff } } func reset() { @@ -204,6 +215,7 @@ final class BLEConnectionScheduler { candidates.removeAll() failureCounts.removeAll() recentConnectTimeouts.removeAll() + recentDisconnects.removeAll() lastIsolatedAt = nil dynamicRSSIThreshold = initialDynamicRSSIThreshold } @@ -225,18 +237,14 @@ final class BLEConnectionScheduler { } lastIsolatedAt = nil + // Flaky links are handled per-peripheral (weak-link cooldown, discovery + // ignore window, score bias) — never globally, so one flaky distant peer + // can't blind us to every other edge-of-range peer. var threshold = TransportConfig.bleDynamicRSSIThresholdDefault if connectedOrConnectingLinkCount >= maxCentralLinks || candidates.count >= candidateCap { threshold = TransportConfig.bleRSSIConnectedThreshold } - let recentTimeouts = recentConnectTimeouts.filter { - now.timeIntervalSince($0.value) < recentTimeoutWindowSeconds - }.count - if recentTimeouts >= recentTimeoutCountThreshold { - threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold) - } - dynamicRSSIThreshold = threshold return threshold } @@ -258,6 +266,20 @@ final class BLEConnectionScheduler { return min(max(2.0, remaining), 15.0) } + // The disconnect settle window must hold on the queue path too: a stale + // candidate enqueued while the peripheral was still connected would + // otherwise reconnect immediately via the post-disconnect queue drain, + // bypassing the window and recreating reconnect/cancel thrash. + private func disconnectSettleDelay( + for candidate: BLEConnectionCandidate, + now: Date + ) -> TimeInterval? { + guard let lastDisconnect = recentDisconnects[candidate.peripheralID] else { return nil } + let remaining = TransportConfig.bleDisconnectDiscoveryIgnoreSeconds - now.timeIntervalSince(lastDisconnect) + guard remaining > 0 else { return nil } + return remaining + 0.05 + } + private func score(_ candidate: BLEConnectionCandidate, now: Date) -> Int { let failures = failureCounts[candidate.peripheralID] ?? 0 let penalty = min(20, 1 << min(4, failures)) diff --git a/bitchat/Services/BLE/BLEFanoutSelector.swift b/bitchat/Services/BLE/BLEFanoutSelector.swift index 7232c642..aa99a8f1 100644 --- a/bitchat/Services/BLE/BLEFanoutSelector.swift +++ b/bitchat/Services/BLE/BLEFanoutSelector.swift @@ -13,15 +13,21 @@ enum BLEFanoutSelector { centralIDs: [String], ingressLink: BLEIngressLinkID?, excludedLinks: Set = [], + peripheralPeerBindings: [String: PeerID] = [:], + centralPeerBindings: [String: PeerID] = [:], directedPeerHint: PeerID?, packetType: UInt8, messageID: String ) -> BLEFanoutSelection { - let allowed = allowedLinks( - peripheralIDs: peripheralIDs, - centralIDs: centralIDs, - ingressLink: ingressLink, - excludedLinks: excludedLinks + let allowed = collapseDuplicateLinksPerPeer( + allowedLinks( + peripheralIDs: peripheralIDs, + centralIDs: centralIDs, + ingressLink: ingressLink, + excludedLinks: excludedLinks + ), + peripheralPeerBindings: peripheralPeerBindings, + centralPeerBindings: centralPeerBindings ) guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else { @@ -65,6 +71,43 @@ enum BLEFanoutSelector { return (allowedPeripheralIDs, allowedCentralIDs) } + // Dual-role pairs hold two live links (we-as-central writing to their + // peripheral, and they-as-central subscribed to ours). Sending the same + // packet down both doubles airtime for nothing — the receiver's assembler + // and deduplicator just discard the copy. Keep one link per bound peer, + // preferring the peripheral (write) side: it has per-link flow control + // via canSendWriteWithoutResponse, while notifications share the + // peripheral manager's update queue across all centrals. Links with no + // bound peer yet (pre-announce) pass through untouched. + private static func collapseDuplicateLinksPerPeer( + _ links: (peripheralIDs: [String], centralIDs: [String]), + peripheralPeerBindings: [String: PeerID], + centralPeerBindings: [String: PeerID] + ) -> (peripheralIDs: [String], centralIDs: [String]) { + guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else { + return links + } + + var seenPeers = Set() + var keptPeripheralIDs: [String] = [] + for id in links.peripheralIDs { + if let peer = peripheralPeerBindings[id], !seenPeers.insert(peer).inserted { + continue + } + keptPeripheralIDs.append(id) + } + + var keptCentralIDs: [String] = [] + for id in links.centralIDs { + if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted { + continue + } + keptCentralIDs.append(id) + } + + return (keptPeripheralIDs, keptCentralIDs) + } + private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool { directedPeerHint == nil && packetType != MessageType.fragment.rawValue diff --git a/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift b/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift index 3924d149..4462ecc8 100644 --- a/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift +++ b/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift @@ -18,6 +18,8 @@ enum BLEOutboundLinkPlanner { centralNotifyLimits: [Int], ingressRecord: BLEIngressLinkRecord?, excludedLinks: Set, + peripheralPeerBindings: [String: PeerID] = [:], + centralPeerBindings: [String: PeerID] = [:], directedOnlyPeer: PeerID? ) -> BLEOutboundLinkPlan { if let minLimit = minimumLinkLimit( @@ -39,6 +41,8 @@ enum BLEOutboundLinkPlanner { centralIDs: centralIDs, ingressLink: ingressRecord?.link, excludedLinks: excludedLinks, + peripheralPeerBindings: peripheralPeerBindings, + centralPeerBindings: centralPeerBindings, directedPeerHint: directedPeerHint, packetType: packet.type, messageID: BLEOutboundPacketPolicy.messageID(for: packet) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 9584c5ae..9959f179 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -966,6 +966,9 @@ final class BLEService: NSObject { let subscribedCentrals = characteristic == nil ? [] : snapshotSubscribedCentrals().centrals let connectedPeripheralIDs = connectedStates.map { $0.peripheral.identifier.uuidString } let centralIDs = subscribedCentrals.map { $0.identifier.uuidString } + let peripheralPeerBindings = Dictionary(uniqueKeysWithValues: connectedStates.compactMap { state in + state.peerID.map { (state.peripheral.identifier.uuidString, $0) } + }) let plan = BLEOutboundLinkPlanner.plan( packet: packet, dataCount: data.count, @@ -975,6 +978,8 @@ final class BLEService: NSObject { centralNotifyLimits: subscribedCentrals.map { $0.maximumUpdateValueLength }, ingressRecord: ingressRecord, excludedLinks: excludedPeerLinks, + peripheralPeerBindings: peripheralPeerBindings, + centralPeerBindings: snapshotSubscribedCentrals().peerIDsByCentralUUID, directedOnlyPeer: directedOnlyPeer ) @@ -3275,6 +3280,13 @@ extension BLEService { // Update scanning duty-cycle based on connectivity updateScanningDutyCycle(connectedCount: connectedCount) updateRSSIThreshold(connectedCount: connectedCount) + + // Drain the connection candidate queue. Weak-RSSI discoveries are + // enqueued rather than connected immediately, and the event-driven + // drains (disconnect/failure/timeout) never fire when we're idle — + // without this, an isolated node surrounded only by weak (distant) + // peers would queue them all and never connect to anyone. + tryConnectFromQueue() // Check peer connectivity every cycle for snappier UI updates checkPeerConnectivity() diff --git a/bitchat/Services/FavoritesPersistenceService.swift b/bitchat/Services/FavoritesPersistenceService.swift index 6e11c00f..b53d640e 100644 --- a/bitchat/Services/FavoritesPersistenceService.swift +++ b/bitchat/Services/FavoritesPersistenceService.swift @@ -41,7 +41,13 @@ final class FavoritesPersistenceService: ObservableObject { /// unchanged. Tests that need their own instance keep injecting a mock /// via `init(keychain:)`. private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol { - TestEnvironment.isRunningTests ? PreviewKeychainManager() : KeychainManager() + // PreviewKeychainManager lives in _PreviewHelpers, a development + // asset excluded from archive builds — release code must not + // reference it. Tests always run Debug, so the guard is lossless. + #if DEBUG + if TestEnvironment.isRunningTests { return PreviewKeychainManager() } + #endif + return KeychainManager() } init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) { diff --git a/bitchat/Services/RelayController.swift b/bitchat/Services/RelayController.swift index 83773120..37fa8584 100644 --- a/bitchat/Services/RelayController.swift +++ b/bitchat/Services/RelayController.swift @@ -39,7 +39,12 @@ struct RelayController { } if isFragment { - let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap) + // Dense graphs clamp harder to contain full-fanout fragment floods; + // sparse graphs get full depth so media reaches as far as text. + let fragmentCap = degree >= highDegreeThreshold + ? TransportConfig.bleFragmentRelayTtlCapDense + : TransportConfig.bleFragmentRelayTtlCap + let ttlLimit = min(ttlCap, fragmentCap) guard ttlLimit > 1 else { return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0) } @@ -50,11 +55,16 @@ struct RelayController { // TTL clamping for broadcast // - Dense graphs: keep lower but still allow multi-hop bridging + // - Thin chains (degree <= 2): every hop counts and flood cost is + // minimal, so relay at full incoming depth // - Announces get a bit more headroom let ttlLimit: UInt8 = { if degree >= highDegreeThreshold { return max(UInt8(2), min(ttlCap, UInt8(5))) } + if degree <= 2 { + return ttlCap + } let preferred = UInt8(isAnnounce ? 7 : 6) return max(UInt8(2), min(ttlCap, preferred)) }() diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 606185bd..4aae7294 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -11,7 +11,10 @@ enum TransportConfig { static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays - static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods + // Fragment relay TTL in sparse graphs; matches messageTTLDefault so media + // reaches as far as text. Dense graphs clamp harder in RelayController. + static let bleFragmentRelayTtlCap: UInt8 = 7 + static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs // UI / Storage Caps static let privateChatCap: Int = 1337 @@ -90,19 +93,21 @@ enum TransportConfig { // BLE maintenance & thresholds static let bleMaintenanceInterval: TimeInterval = 5.0 static let bleMaintenanceLeewaySeconds: Int = 1 - static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60 - static let bleRecentTimeoutWindowSeconds: TimeInterval = 60 - static let bleRecentTimeoutCountThreshold: Int = 3 - static let bleRSSIIsolatedBase: Int = -90 - static let bleRSSIIsolatedRelaxed: Int = -92 + static let bleIsolationRelaxThresholdSeconds: TimeInterval = 30 + // Isolated nodes accept the weakest usable links — a fringe connection + // beats no connection. Relaxed floor sits at CoreBluetooth's practical + // reporting limit so prolonged isolation gates on nothing but decode. + static let bleRSSIIsolatedBase: Int = -95 + static let bleRSSIIsolatedRelaxed: Int = -100 static let bleRSSIConnectedThreshold: Int = -85 - static let bleRSSIHighTimeoutThreshold: Int = -80 // How long without seeing traffic before we sanity-check the direct link // Lowered to make connected→reachable icon changes react faster when walking out of range static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0 - // How long to retain a peer as "reachable" (not directly connected) since lastSeen - static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites - static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified + // How long to retain a peer as "reachable" (not directly connected) since lastSeen. + // Must comfortably exceed the worst-case dense announce interval (38s) plus a + // missed cycle, so duty-cycled nodes don't forget peers between announces. + static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 60.0 // verified/favorites + static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 45.0 // unknown/unverified static let bleFragmentLifetimeSeconds: TimeInterval = 30.0 static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0 static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0 @@ -203,8 +208,10 @@ enum TransportConfig { static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown - // Store-and-forward for directed packets at relays - static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0 + // Store-and-forward for directed packets at relays. Spooled packets retry + // on each maintenance flush until the window lapses; a longer window lets + // brief link gaps (walking between rooms, reconnect churn) heal themselves. + static let bleDirectedSpoolWindowSeconds: TimeInterval = 60.0 // Log/UI debounce windows // Shorter debounce so UI reacts faster while still suppressing duplicate callbacks @@ -214,6 +221,12 @@ enum TransportConfig { // Weak-link cooldown after connection timeouts static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0 static let bleWeakLinkRSSICutoff: Int = -90 + // Rediscovery ignore windows after a failed link, by failure kind: + // a connect attempt that timed out means the peer likely isn't reachable, + // so back off; a dropped established connection (walked out of range) + // usually returns, so only pause long enough for CoreBluetooth to settle. + static let bleTimeoutDiscoveryIgnoreSeconds: TimeInterval = 15.0 + static let bleDisconnectDiscoveryIgnoreSeconds: TimeInterval = 3.0 // Content hashing / formatting static let contentKeyPrefixLength: Int = 256 diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index 593aaf83..77a40c6b 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -65,13 +65,23 @@ extension ChatViewModel: ChatPeerListContext { final class ChatPeerListCoordinator: @unchecked Sendable { private unowned let context: any ChatPeerListContext private var recentlySeenPeers: Set = [] + // The "bitchatters nearby" notification only fires on the transition from + // an empty mesh to a populated one — joining peers while already meshed + // are visible in the app and must not notify. Set back to true only after + // a confirmed-empty reset, so brief link flaps stay silent. + private var meshWasEmpty = true private var lastNetworkNotificationTime = Date.distantPast private var networkResetTimer: Timer? private var networkEmptyTimer: Timer? private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds + private let notificationCooldownSeconds: TimeInterval - init(context: any ChatPeerListContext) { + init( + context: any ChatPeerListContext, + notificationCooldownSeconds: TimeInterval = TransportConfig.networkNotificationCooldownSeconds + ) { self.context = context + self.notificationCooldownSeconds = notificationCooldownSeconds } deinit { @@ -121,11 +131,18 @@ private extension ChatPeerListCoordinator { invalidateNetworkEmptyTimer() let newPeers = meshPeerSet.subtracting(recentlySeenPeers) - guard !newPeers.isEmpty else { return } + // Record every sighted peer even when no notification fires. A peer + // first seen during the cooldown (or while already meshed) must not + // still count as "new" at some later peer-list event — that re-fired + // the notification while devices sat idle and connected. + recentlySeenPeers.formUnion(meshPeerSet) - let cooldown = TransportConfig.networkNotificationCooldownSeconds - if Date().timeIntervalSince(lastNetworkNotificationTime) >= cooldown { - recentlySeenPeers.formUnion(newPeers) + let cameFromEmpty = meshWasEmpty + meshWasEmpty = false + + guard cameFromEmpty, !newPeers.isEmpty else { return } + + if Date().timeIntervalSince(lastNetworkNotificationTime) >= notificationCooldownSeconds { lastNetworkNotificationTime = Date() context.notifyNetworkAvailable(peerCount: meshPeers.count) SecureLogger.info( @@ -185,6 +202,7 @@ private extension ChatPeerListCoordinator { if activeMeshPeerCount == 0 { recentlySeenPeers.removeAll() + meshWasEmpty = true SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session) } else { SecureLogger.debug( @@ -225,6 +243,7 @@ private extension ChatPeerListCoordinator { if activeMeshPeerCount == 0 { recentlySeenPeers.removeAll() + meshWasEmpty = true SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session) } else { SecureLogger.debug( diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 0b84961d..63346e26 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -81,6 +81,10 @@ struct TextMessageView: View { } } +// Wrapped in #if DEBUG because the preview depends on _PreviewHelpers +// (PreviewKeychainManager, BitchatMessage.preview), a development asset +// excluded from archive builds. +#if DEBUG #Preview { let keychain = PreviewKeychainManager() let viewModel = ChatViewModel( @@ -117,3 +121,4 @@ struct TextMessageView: View { } .environmentObject(conversationUIModel) } +#endif diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift index e4abb1b3..62d1c538 100644 --- a/bitchatTests/ChatPeerListCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -201,6 +201,52 @@ struct ChatPeerListCoordinatorContextTests { #expect(context.networkAvailableNotifications == [1]) } + @Test @MainActor + func didUpdatePeerList_peerJoiningExistingMeshDoesNotNotify() async { + // Cooldown zero so this proves the empty-transition gate alone — a + // new peer joining while already meshed must stay silent even with + // the cooldown long expired (the sitting-idle re-notify bug). + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0) + let peerA = PeerID(str: "0011223344556677") + let peerB = PeerID(str: "8899aabbccddeeff") + context.connectedMeshPeers = [peerA, peerB] + + coordinator.didUpdatePeerList([peerA]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1]) + + // peerB arrives while peerA is still connected: no notification. + coordinator.didUpdatePeerList([peerA, peerB]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1]) + + // Repeat events while idle keep staying silent. + coordinator.didUpdatePeerList([peerA, peerB]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1]) + } + + @Test @MainActor + func didUpdatePeerList_briefMeshFlapDoesNotRenotify() async { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0) + let peerA = PeerID(str: "0011223344556677") + context.connectedMeshPeers = [peerA] + + coordinator.didUpdatePeerList([peerA]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1]) + + // Link flap: empty list, then the peer returns before the 30s empty + // confirmation fires — silent. + coordinator.didUpdatePeerList([]) + await drainMainActorTasks() + coordinator.didUpdatePeerList([peerA]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1]) + } + @Test @MainActor func didUpdatePeerList_meshInactivePeersNeverNotify() async { let context = MockChatPeerListContext() diff --git a/bitchatTests/Services/BLEConnectionSchedulerTests.swift b/bitchatTests/Services/BLEConnectionSchedulerTests.swift index f88588f5..6746a461 100644 --- a/bitchatTests/Services/BLEConnectionSchedulerTests.swift +++ b/bitchatTests/Services/BLEConnectionSchedulerTests.swift @@ -110,7 +110,87 @@ struct BLEConnectionSchedulerTests { } @Test - func rssiThresholdTightensAfterRepeatedRecentTimeouts() { + func disconnectErrorOnlyBrieflyBlocksRediscovery() { + // A dropped established connection (walked out of range) gets a short + // settle window, not the full connect-timeout backoff. + let scheduler = BLEConnectionScheduler() + let now = Date() + scheduler.recordDisconnectError(peripheralID: "p1", at: now) + + let during = scheduler.handleDiscovery( + makeCandidate(id: "p1", rssi: -80, now: now.addingTimeInterval(1)), + connectedOrConnectingCount: 0, + existingState: nil, + peripheralState: .disconnected, + now: now.addingTimeInterval(1) + ) + #expect(during == .ignore) + + let afterWindow = now.addingTimeInterval(TransportConfig.bleDisconnectDiscoveryIgnoreSeconds + 1) + let after = scheduler.handleDiscovery( + makeCandidate(id: "p1", rssi: -80, now: afterWindow), + connectedOrConnectingCount: 0, + existingState: nil, + peripheralState: .disconnected, + now: afterWindow + ) + #expect(after == .connectNow) + } + + @Test + func disconnectSettleWindowAppliesToQueuedCandidates() { + // A candidate can already be queued when its peripheral drops (weak + // adverts are enqueued even while connected). The post-disconnect + // queue drain must honor the settle window, not reconnect instantly. + let scheduler = BLEConnectionScheduler() + let now = Date() + scheduler.enqueue(makeCandidate(id: "p1", rssi: -85, now: now)) + scheduler.recordDisconnectError(peripheralID: "p1", at: now) + + let during = scheduler.nextCandidate( + connectedOrConnectingCount: 0, + isAlreadyConnectingOrConnected: { _ in false }, + now: now.addingTimeInterval(0.1) + ) + guard case .retryAfter(let delay) = during else { + Issue.record("Expected retryAfter during settle window, got \(during)") + return + } + #expect(delay > 0) + #expect(scheduler.candidateCount == 1) + + let after = scheduler.nextCandidate( + connectedOrConnectingCount: 0, + isAlreadyConnectingOrConnected: { _ in false }, + now: now.addingTimeInterval(TransportConfig.bleDisconnectDiscoveryIgnoreSeconds + 1) + ) + guard case .connect(let candidate) = after else { + Issue.record("Expected connect after settle window, got \(after)") + return + } + #expect(candidate.peripheralID == "p1") + } + + @Test + func connectTimeoutBlocksRediscoveryForFullWindow() { + let scheduler = BLEConnectionScheduler() + let now = Date() + scheduler.recordConnectionTimeout(peripheralID: "p1", at: now) + + let midWindow = scheduler.handleDiscovery( + makeCandidate(id: "p1", rssi: -80, now: now.addingTimeInterval(10)), + connectedOrConnectingCount: 0, + existingState: nil, + peripheralState: .disconnected, + now: now.addingTimeInterval(10) + ) + #expect(midWindow == .ignore) + } + + @Test + func repeatedTimeoutsDoNotTightenGlobalRSSIThreshold() { + // Flaky links are penalized per-peripheral only; timeouts from a few + // distant peers must not blind us to every other edge-of-range peer. let scheduler = BLEConnectionScheduler() let now = Date() scheduler.recordConnectionTimeout(peripheralID: "p1", at: now) @@ -123,8 +203,27 @@ struct BLEConnectionSchedulerTests { now: now.addingTimeInterval(1) ) - #expect(threshold == TransportConfig.bleRSSIHighTimeoutThreshold) - #expect(scheduler.dynamicRSSIThreshold == TransportConfig.bleRSSIHighTimeoutThreshold) + #expect(threshold == TransportConfig.bleDynamicRSSIThresholdDefault) + } + + @Test + func isolationRelaxesRSSIThresholdOverTime() { + let scheduler = BLEConnectionScheduler() + let now = Date() + + let initial = scheduler.updateRSSIThreshold( + connectedCount: 0, + connectedOrConnectingLinkCount: 0, + now: now + ) + #expect(initial == TransportConfig.bleRSSIIsolatedBase) + + let relaxed = scheduler.updateRSSIThreshold( + connectedCount: 0, + connectedOrConnectingLinkCount: 0, + now: now.addingTimeInterval(TransportConfig.bleIsolationRelaxThresholdSeconds + 1) + ) + #expect(relaxed == TransportConfig.bleRSSIIsolatedRelaxed) } @Test diff --git a/bitchatTests/Services/BLEFanoutSelectorTests.swift b/bitchatTests/Services/BLEFanoutSelectorTests.swift index feec13db..43146365 100644 --- a/bitchatTests/Services/BLEFanoutSelectorTests.swift +++ b/bitchatTests/Services/BLEFanoutSelectorTests.swift @@ -78,6 +78,47 @@ struct BLEFanoutSelectorTests { #expect(first.centralIDs.count == 4) } + @Test + func dualLinkPeerRelaysOnSingleLinkPreferringPeripheral() { + // A dual-role pair holds two live links to the same peer; relays must + // not transmit the same packet down both. The peripheral (write) link + // wins because it has per-link flow control. + let peer = PeerID(str: "1122334455667788") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["p1"], + centralIDs: ["c1"], + ingressLink: nil, + peripheralPeerBindings: ["p1": peer], + centralPeerBindings: ["c1": peer], + directedPeerHint: nil, + packetType: MessageType.fragment.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs == Set(["p1"])) + #expect(selection.centralIDs.isEmpty) + } + + @Test + func unboundLinksSurviveDuplicatePeerCollapse() { + // Links whose peer is not yet known (pre-announce) must keep + // receiving broadcasts alongside a deduplicated bound pair. + let peer = PeerID(str: "1122334455667788") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["p1"], + centralIDs: ["c-bound", "c-unbound"], + ingressLink: nil, + peripheralPeerBindings: ["p1": peer], + centralPeerBindings: ["c-bound": peer], + directedPeerHint: nil, + packetType: MessageType.fragment.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs == Set(["p1"])) + #expect(selection.centralIDs == Set(["c-unbound"])) + } + @Test func broadcastWithTwoLinksKeepsBothAfterIngressExclusion() { let selection = BLEFanoutSelector.selectLinks( diff --git a/bitchatTests/Services/RelayControllerTests.swift b/bitchatTests/Services/RelayControllerTests.swift index ef5b5ff2..c0c473cf 100644 --- a/bitchatTests/Services/RelayControllerTests.swift +++ b/bitchatTests/Services/RelayControllerTests.swift @@ -94,6 +94,46 @@ struct RelayControllerTests { #expect(decision.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs) } + @Test + func sparseChain_relaysAtFullIncomingDepth() async { + // Thin chains (degree <= 2) are exactly the topology that needs every + // hop, so no clamp below the incoming TTL is applied. + let decision = RelayController.decide( + ttl: 7, + senderIsSelf: false, + isEncrypted: false, + isDirectedEncrypted: false, + isFragment: false, + isDirectedFragment: false, + isHandshake: false, + isAnnounce: false, + degree: 2, + highDegreeThreshold: TransportConfig.bleHighDegreeThreshold + ) + + #expect(decision.shouldRelay) + #expect(decision.newTTL == 6) + } + + @Test + func denseGraph_clampsFragmentTTLHarder() async { + let decision = RelayController.decide( + ttl: 10, + senderIsSelf: false, + isEncrypted: false, + isDirectedEncrypted: false, + isFragment: true, + isDirectedFragment: false, + isHandshake: false, + isAnnounce: false, + degree: TransportConfig.bleHighDegreeThreshold, + highDegreeThreshold: TransportConfig.bleHighDegreeThreshold + ) + + #expect(decision.shouldRelay) + #expect(decision.newTTL == TransportConfig.bleFragmentRelayTtlCapDense - 1) + } + @Test func denseGraph_capsTTL() async { let decision = RelayController.decide(