Extend BLE mesh range: relax RSSI gates, lift sparse TTL clamps, faster walk-back reconnects (#1338)

* Extend mesh range: relax RSSI gates, lift sparse TTL clamps, drain connection queue

Range improvements to the BLE mesh, all policy-level (no wire/protocol
changes):

- Drain the connection candidate queue from the maintenance tick.
  Weak-RSSI discoveries are enqueued rather than connected, but the
  queue was only drained on disconnect/failure/timeout events — an
  isolated node surrounded only by weak (distant) peers queued them
  all and never connected to anyone.
- Relax isolated RSSI floors from -90/-92 to -95/-100 and relax after
  30s instead of 60s. When isolated, a fringe connection beats no
  connection; CoreBluetooth rarely reports below -100 so prolonged
  isolation now effectively accepts any decodable peer.
- Drop the global high-timeout RSSI escalation (-80 after 3 timeouts
  in 60s). One flaky distant peer could blind the node to every other
  edge-of-range peer; per-peripheral cooldown, the discovery ignore
  window, and score bias already contain flaky links individually.
- Relay at full incoming TTL in thin chains (degree <= 2). Sparse
  line topologies are exactly where every hop counts and where flood
  cost is minimal; previously messages lost a hop to the clamp.
- Raise the fragment relay TTL cap from 5 to 7 in sparse graphs so
  media reaches as far as text; dense graphs keep the 5-hop clamp to
  contain full-fanout fragment floods.
- Extend peer reachability retention from 21s to 60s (verified) /
  45s (unverified) so duty-cycled nodes (worst-case dense announce
  interval 38s) don't forget peers between announces.
- Extend the directed store-and-forward spool window from 15s to 60s
  so brief link gaps heal via the periodic flush.

957 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reconnect quickly after walk-away disconnects

Field test (walk away + return between two devices) showed reconnect
landing exactly 15.0s after the supervision-timeout disconnect: the
scheduler records a dropped established connection via
recordDisconnectError into the same map as connect timeouts, and
handleDiscovery hard-ignores rediscoveries for 15s.

Those are different situations. A connect attempt that timed out means
the peer likely isn't reachable, so backing off is right. A dropped
established connection usually means the peer walked out of range and
will return — track it separately and only ignore rediscoveries for 3s
(enough for CoreBluetooth to settle), so walking back into range
reconnects ~12s sooner.

Disconnect errors also no longer feed the weak-link cooldown or the
candidate-score timeout bias; those penalties now apply only to peers
that never answered a connect attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Honor the disconnect settle window on the queue drain path

Codex review caught that the 3s settle window was only enforced in
handleDiscovery. A candidate can already be sitting in the queue when
its peripheral drops (weak-RSSI adverts are enqueued even while
connected, since the RSSI check precedes the existing-state check),
and didDisconnectPeripheral immediately drains the queue — so the
stale entry could reconnect right through the window, recreating the
reconnect/cancel thrash it exists to prevent.

nextCandidate now defers such candidates with retryAfter for the
window's remainder, mirroring the weak-link cooldown pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Relay on one link per bound peer instead of both dual-role links

Three-device field test (star topology) showed every relayed fragment
arriving twice at the leaf: dual-role pairs hold two live links (we as
central writing to their peripheral, they as central subscribed to
ours) and broadcast/relay fanout sent the same packet down both — 2x
airtime on exactly the pairs that talk most, with the receiver just
discarding the duplicate.

The fanout selector now collapses link selection to one link per bound
peer, preferring the peripheral (write) side since 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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Only notify "bitchatters nearby" on the empty-to-populated transition

Devices sitting idle and connected kept re-firing the notification.
Two bugs in handleNetworkAvailability:

- Peers first sighted during the 5-minute cooldown were never added to
  recentlySeenPeers (the formUnion only ran when a notification
  fired), so they stayed "new" forever and re-triggered on the next
  routine peer-list event once the cooldown lapsed.
- There was no went-from-zero gate at all: any unseen peer notified,
  even while already meshed with others who are visible in the app.

Every sighted peer is now recorded regardless of cooldown, and the
notification only fires when the mesh transitions from confirmed-empty
to populated with genuinely new peers. meshWasEmpty resets only via
the existing confirmed-empty paths (30s empty confirmation, 10-minute
quiet reset), so brief link flaps stay silent. The cooldown becomes
injectable so tests can prove the transition gate independently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bump version to 1.5.2; Xcode 26.5 project settings update

Marketing version 1.5.1 -> 1.5.2 (pbxproj + Release.xcconfig).
Project settings refresh from Xcode 26.5: upgrade-check stamp, drop
redundant DEVELOPMENT_TEAM self-references, scheme version stamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Disable string catalog symbol generation

The Xcode 26.5 settings refresh enabled STRING_CATALOG_GENERATE_SYMBOLS
(the new default), which fails on the literal "%@" key in
Localizable.xcstrings — a pure format placeholder can't become a Swift
identifier. Nothing in the codebase references generated catalog
symbols, so turn the feature off rather than renaming keys around it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Guard _PreviewHelpers references for archive builds

Archiving for TestFlight failed: _PreviewHelpers is a development
asset, so its sources (PreviewKeychainManager, BitchatMessage.preview)
are excluded from Release/archive builds, and two call sites
referenced them unconditionally:

- TextMessageView's #Preview block — now wrapped in #if DEBUG
- FavoritesPersistenceService.makeDefaultKeychain's test branch — the
  in-memory-keychain-under-test path is now #if DEBUG; tests always
  run Debug so behavior is unchanged, and Release always gets the real
  KeychainManager

Verified with an iOS Release arm64 build (the archive configuration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-12 10:38:39 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent 97bc3f53bc
commit 266827ceff
17 changed files with 416 additions and 60 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.5.1 MARKETING_VERSION = 1.5.2
CURRENT_PROJECT_VERSION = 1 CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0 IPHONEOS_DEPLOYMENT_TARGET = 16.0
+11 -15
View File
@@ -321,7 +321,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1640; LastUpgradeCheck = 2650;
}; };
buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */; buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */;
developmentRegion = en; developmentRegion = en;
@@ -446,7 +446,6 @@
CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist; INFOPLIST_FILE = bitchatTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -471,7 +470,6 @@
CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist; INFOPLIST_FILE = bitchatTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -498,7 +496,6 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist; INFOPLIST_FILE = bitchatTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -523,7 +520,6 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatShareExtension/Info.plist; INFOPLIST_FILE = bitchatShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
@@ -556,7 +552,6 @@
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers; DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO; ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -566,7 +561,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = 1.5.2;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -590,7 +585,6 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist; INFOPLIST_FILE = bitchatTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -617,7 +611,6 @@
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers; DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -627,7 +620,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = 1.5.2;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -653,7 +646,6 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -663,7 +655,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = 1.5.2;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -676,6 +668,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -709,6 +702,7 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -726,6 +720,7 @@
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = "$(SWIFT_VERSION)"; SWIFT_VERSION = "$(SWIFT_VERSION)";
@@ -745,7 +740,6 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO; ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -755,7 +749,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = 1.5.2;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -768,6 +762,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -801,6 +796,7 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -825,6 +821,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = "$(SWIFT_VERSION)"; SWIFT_VERSION = "$(SWIFT_VERSION)";
@@ -841,7 +838,6 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatShareExtension/Info.plist; INFOPLIST_FILE = bitchatShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat; INFOPLIST_KEY_CFBundleDisplayName = bitchat;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1640" LastUpgradeVersion = "2650"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1640" LastUpgradeVersion = "2650"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@@ -41,13 +41,16 @@ final class BLEConnectionScheduler<Peripheral> {
private let candidateCap: Int private let candidateCap: Int
private let weakLinkCooldownSeconds: TimeInterval private let weakLinkCooldownSeconds: TimeInterval
private let weakLinkRSSICutoff: Int private let weakLinkRSSICutoff: Int
private let recentTimeoutWindowSeconds: TimeInterval
private let recentTimeoutCountThreshold: Int
private var lastGlobalConnectAttempt: Date = .distantPast private var lastGlobalConnectAttempt: Date = .distantPast
private var candidates: [BLEConnectionCandidate<Peripheral>] = [] private var candidates: [BLEConnectionCandidate<Peripheral>] = []
private var failureCounts: [String: Int] = [:] private var failureCounts: [String: Int] = [:]
private var recentConnectTimeouts: [String: Date] = [:] 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 var lastIsolatedAt: Date?
private let initialDynamicRSSIThreshold: Int private let initialDynamicRSSIThreshold: Int
@@ -63,8 +66,6 @@ final class BLEConnectionScheduler<Peripheral> {
candidateCap: Int = TransportConfig.bleConnectionCandidatesMax, candidateCap: Int = TransportConfig.bleConnectionCandidatesMax,
weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds, weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds,
weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff, weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff,
recentTimeoutWindowSeconds: TimeInterval = TransportConfig.bleRecentTimeoutWindowSeconds,
recentTimeoutCountThreshold: Int = TransportConfig.bleRecentTimeoutCountThreshold,
dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
) { ) {
self.maxCentralLinks = maxCentralLinks self.maxCentralLinks = maxCentralLinks
@@ -72,8 +73,6 @@ final class BLEConnectionScheduler<Peripheral> {
self.candidateCap = candidateCap self.candidateCap = candidateCap
self.weakLinkCooldownSeconds = weakLinkCooldownSeconds self.weakLinkCooldownSeconds = weakLinkCooldownSeconds
self.weakLinkRSSICutoff = weakLinkRSSICutoff self.weakLinkRSSICutoff = weakLinkRSSICutoff
self.recentTimeoutWindowSeconds = recentTimeoutWindowSeconds
self.recentTimeoutCountThreshold = recentTimeoutCountThreshold
self.initialDynamicRSSIThreshold = dynamicRSSIThreshold self.initialDynamicRSSIThreshold = dynamicRSSIThreshold
self.dynamicRSSIThreshold = dynamicRSSIThreshold self.dynamicRSSIThreshold = dynamicRSSIThreshold
} }
@@ -114,7 +113,12 @@ final class BLEConnectionScheduler<Peripheral> {
} }
if let lastTimeout = recentConnectTimeouts[candidate.peripheralID], 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 return .ignore
} }
@@ -163,6 +167,11 @@ final class BLEConnectionScheduler<Peripheral> {
return .retryAfter(delay) return .retryAfter(delay)
} }
if let delay = disconnectSettleDelay(for: candidate, now: now) {
enqueue(candidate)
return .retryAfter(delay)
}
if isAlreadyConnectingOrConnected(candidate.peripheralID) { if isAlreadyConnectingOrConnected(candidate.peripheralID) {
continue continue
} }
@@ -180,6 +189,7 @@ final class BLEConnectionScheduler<Peripheral> {
func recordConnectionSuccess(peripheralID: String) { func recordConnectionSuccess(peripheralID: String) {
failureCounts[peripheralID] = 0 failureCounts[peripheralID] = 0
recentConnectTimeouts.removeValue(forKey: peripheralID) recentConnectTimeouts.removeValue(forKey: peripheralID)
recentDisconnects.removeValue(forKey: peripheralID)
} }
func recordConnectionFailure(peripheralID: String) { func recordConnectionFailure(peripheralID: String) {
@@ -187,7 +197,7 @@ final class BLEConnectionScheduler<Peripheral> {
} }
func recordDisconnectError(peripheralID: String, at now: Date) { func recordDisconnectError(peripheralID: String, at now: Date) {
recentConnectTimeouts[peripheralID] = now recentDisconnects[peripheralID] = now
} }
func recordConnectionTimeout(peripheralID: String, at now: Date) { func recordConnectionTimeout(peripheralID: String, at now: Date) {
@@ -197,6 +207,7 @@ final class BLEConnectionScheduler<Peripheral> {
func pruneConnectionTimeouts(before cutoff: Date) { func pruneConnectionTimeouts(before cutoff: Date) {
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff } recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff }
recentDisconnects = recentDisconnects.filter { $0.value >= cutoff }
} }
func reset() { func reset() {
@@ -204,6 +215,7 @@ final class BLEConnectionScheduler<Peripheral> {
candidates.removeAll() candidates.removeAll()
failureCounts.removeAll() failureCounts.removeAll()
recentConnectTimeouts.removeAll() recentConnectTimeouts.removeAll()
recentDisconnects.removeAll()
lastIsolatedAt = nil lastIsolatedAt = nil
dynamicRSSIThreshold = initialDynamicRSSIThreshold dynamicRSSIThreshold = initialDynamicRSSIThreshold
} }
@@ -225,18 +237,14 @@ final class BLEConnectionScheduler<Peripheral> {
} }
lastIsolatedAt = nil 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 var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
if connectedOrConnectingLinkCount >= maxCentralLinks || candidates.count >= candidateCap { if connectedOrConnectingLinkCount >= maxCentralLinks || candidates.count >= candidateCap {
threshold = TransportConfig.bleRSSIConnectedThreshold threshold = TransportConfig.bleRSSIConnectedThreshold
} }
let recentTimeouts = recentConnectTimeouts.filter {
now.timeIntervalSince($0.value) < recentTimeoutWindowSeconds
}.count
if recentTimeouts >= recentTimeoutCountThreshold {
threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold)
}
dynamicRSSIThreshold = threshold dynamicRSSIThreshold = threshold
return threshold return threshold
} }
@@ -258,6 +266,20 @@ final class BLEConnectionScheduler<Peripheral> {
return min(max(2.0, remaining), 15.0) 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<Peripheral>,
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<Peripheral>, now: Date) -> Int { private func score(_ candidate: BLEConnectionCandidate<Peripheral>, now: Date) -> Int {
let failures = failureCounts[candidate.peripheralID] ?? 0 let failures = failureCounts[candidate.peripheralID] ?? 0
let penalty = min(20, 1 << min(4, failures)) let penalty = min(20, 1 << min(4, failures))
+48 -5
View File
@@ -13,15 +13,21 @@ enum BLEFanoutSelector {
centralIDs: [String], centralIDs: [String],
ingressLink: BLEIngressLinkID?, ingressLink: BLEIngressLinkID?,
excludedLinks: Set<BLEIngressLinkID> = [], excludedLinks: Set<BLEIngressLinkID> = [],
peripheralPeerBindings: [String: PeerID] = [:],
centralPeerBindings: [String: PeerID] = [:],
directedPeerHint: PeerID?, directedPeerHint: PeerID?,
packetType: UInt8, packetType: UInt8,
messageID: String messageID: String
) -> BLEFanoutSelection { ) -> BLEFanoutSelection {
let allowed = allowedLinks( let allowed = collapseDuplicateLinksPerPeer(
peripheralIDs: peripheralIDs, allowedLinks(
centralIDs: centralIDs, peripheralIDs: peripheralIDs,
ingressLink: ingressLink, centralIDs: centralIDs,
excludedLinks: excludedLinks ingressLink: ingressLink,
excludedLinks: excludedLinks
),
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
) )
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else { guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
@@ -65,6 +71,43 @@ enum BLEFanoutSelector {
return (allowedPeripheralIDs, allowedCentralIDs) 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<PeerID>()
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 { private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool {
directedPeerHint == nil directedPeerHint == nil
&& packetType != MessageType.fragment.rawValue && packetType != MessageType.fragment.rawValue
@@ -18,6 +18,8 @@ enum BLEOutboundLinkPlanner {
centralNotifyLimits: [Int], centralNotifyLimits: [Int],
ingressRecord: BLEIngressLinkRecord?, ingressRecord: BLEIngressLinkRecord?,
excludedLinks: Set<BLEIngressLinkID>, excludedLinks: Set<BLEIngressLinkID>,
peripheralPeerBindings: [String: PeerID] = [:],
centralPeerBindings: [String: PeerID] = [:],
directedOnlyPeer: PeerID? directedOnlyPeer: PeerID?
) -> BLEOutboundLinkPlan { ) -> BLEOutboundLinkPlan {
if let minLimit = minimumLinkLimit( if let minLimit = minimumLinkLimit(
@@ -39,6 +41,8 @@ enum BLEOutboundLinkPlanner {
centralIDs: centralIDs, centralIDs: centralIDs,
ingressLink: ingressRecord?.link, ingressLink: ingressRecord?.link,
excludedLinks: excludedLinks, excludedLinks: excludedLinks,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings,
directedPeerHint: directedPeerHint, directedPeerHint: directedPeerHint,
packetType: packet.type, packetType: packet.type,
messageID: BLEOutboundPacketPolicy.messageID(for: packet) messageID: BLEOutboundPacketPolicy.messageID(for: packet)
+12
View File
@@ -966,6 +966,9 @@ final class BLEService: NSObject {
let subscribedCentrals = characteristic == nil ? [] : snapshotSubscribedCentrals().centrals let subscribedCentrals = characteristic == nil ? [] : snapshotSubscribedCentrals().centrals
let connectedPeripheralIDs = connectedStates.map { $0.peripheral.identifier.uuidString } let connectedPeripheralIDs = connectedStates.map { $0.peripheral.identifier.uuidString }
let centralIDs = subscribedCentrals.map { $0.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( let plan = BLEOutboundLinkPlanner.plan(
packet: packet, packet: packet,
dataCount: data.count, dataCount: data.count,
@@ -975,6 +978,8 @@ final class BLEService: NSObject {
centralNotifyLimits: subscribedCentrals.map { $0.maximumUpdateValueLength }, centralNotifyLimits: subscribedCentrals.map { $0.maximumUpdateValueLength },
ingressRecord: ingressRecord, ingressRecord: ingressRecord,
excludedLinks: excludedPeerLinks, excludedLinks: excludedPeerLinks,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: snapshotSubscribedCentrals().peerIDsByCentralUUID,
directedOnlyPeer: directedOnlyPeer directedOnlyPeer: directedOnlyPeer
) )
@@ -3275,6 +3280,13 @@ extension BLEService {
// Update scanning duty-cycle based on connectivity // Update scanning duty-cycle based on connectivity
updateScanningDutyCycle(connectedCount: connectedCount) updateScanningDutyCycle(connectedCount: connectedCount)
updateRSSIThreshold(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 // Check peer connectivity every cycle for snappier UI updates
checkPeerConnectivity() checkPeerConnectivity()
@@ -41,7 +41,13 @@ final class FavoritesPersistenceService: ObservableObject {
/// unchanged. Tests that need their own instance keep injecting a mock /// unchanged. Tests that need their own instance keep injecting a mock
/// via `init(keychain:)`. /// via `init(keychain:)`.
private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol { 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()) { init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) {
+11 -1
View File
@@ -39,7 +39,12 @@ struct RelayController {
} }
if isFragment { 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 { guard ttlLimit > 1 else {
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0) return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
} }
@@ -50,11 +55,16 @@ struct RelayController {
// TTL clamping for broadcast // TTL clamping for broadcast
// - Dense graphs: keep lower but still allow multi-hop bridging // - 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 // - Announces get a bit more headroom
let ttlLimit: UInt8 = { let ttlLimit: UInt8 = {
if degree >= highDegreeThreshold { if degree >= highDegreeThreshold {
return max(UInt8(2), min(ttlCap, UInt8(5))) return max(UInt8(2), min(ttlCap, UInt8(5)))
} }
if degree <= 2 {
return ttlCap
}
let preferred = UInt8(isAnnounce ? 7 : 6) let preferred = UInt8(isAnnounce ? 7 : 6)
return max(UInt8(2), min(ttlCap, preferred)) return max(UInt8(2), min(ttlCap, preferred))
}() }()
+25 -12
View File
@@ -11,7 +11,10 @@ enum TransportConfig {
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays 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 // UI / Storage Caps
static let privateChatCap: Int = 1337 static let privateChatCap: Int = 1337
@@ -90,19 +93,21 @@ enum TransportConfig {
// BLE maintenance & thresholds // BLE maintenance & thresholds
static let bleMaintenanceInterval: TimeInterval = 5.0 static let bleMaintenanceInterval: TimeInterval = 5.0
static let bleMaintenanceLeewaySeconds: Int = 1 static let bleMaintenanceLeewaySeconds: Int = 1
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60 static let bleIsolationRelaxThresholdSeconds: TimeInterval = 30
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60 // Isolated nodes accept the weakest usable links a fringe connection
static let bleRecentTimeoutCountThreshold: Int = 3 // beats no connection. Relaxed floor sits at CoreBluetooth's practical
static let bleRSSIIsolatedBase: Int = -90 // reporting limit so prolonged isolation gates on nothing but decode.
static let bleRSSIIsolatedRelaxed: Int = -92 static let bleRSSIIsolatedBase: Int = -95
static let bleRSSIIsolatedRelaxed: Int = -100
static let bleRSSIConnectedThreshold: Int = -85 static let bleRSSIConnectedThreshold: Int = -85
static let bleRSSIHighTimeoutThreshold: Int = -80
// How long without seeing traffic before we sanity-check the direct link // How long without seeing traffic before we sanity-check the direct link
// Lowered to make connectedreachable icon changes react faster when walking out of range // Lowered to make connectedreachable icon changes react faster when walking out of range
static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0 static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0
// How long to retain a peer as "reachable" (not directly connected) since lastSeen // How long to retain a peer as "reachable" (not directly connected) since lastSeen.
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites // Must comfortably exceed the worst-case dense announce interval (38s) plus a
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified // 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 bleFragmentLifetimeSeconds: TimeInterval = 30.0
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0 static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.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 bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
// Store-and-forward for directed packets at relays // Store-and-forward for directed packets at relays. Spooled packets retry
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0 // 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 // Log/UI debounce windows
// Shorter debounce so UI reacts faster while still suppressing duplicate callbacks // Shorter debounce so UI reacts faster while still suppressing duplicate callbacks
@@ -214,6 +221,12 @@ enum TransportConfig {
// Weak-link cooldown after connection timeouts // Weak-link cooldown after connection timeouts
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0 static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
static let bleWeakLinkRSSICutoff: Int = -90 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 // Content hashing / formatting
static let contentKeyPrefixLength: Int = 256 static let contentKeyPrefixLength: Int = 256
@@ -65,13 +65,23 @@ extension ChatViewModel: ChatPeerListContext {
final class ChatPeerListCoordinator: @unchecked Sendable { final class ChatPeerListCoordinator: @unchecked Sendable {
private unowned let context: any ChatPeerListContext private unowned let context: any ChatPeerListContext
private var recentlySeenPeers: Set<PeerID> = [] private var recentlySeenPeers: Set<PeerID> = []
// 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 lastNetworkNotificationTime = Date.distantPast
private var networkResetTimer: Timer? private var networkResetTimer: Timer?
private var networkEmptyTimer: Timer? private var networkEmptyTimer: Timer?
private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds 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.context = context
self.notificationCooldownSeconds = notificationCooldownSeconds
} }
deinit { deinit {
@@ -121,11 +131,18 @@ private extension ChatPeerListCoordinator {
invalidateNetworkEmptyTimer() invalidateNetworkEmptyTimer()
let newPeers = meshPeerSet.subtracting(recentlySeenPeers) 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 let cameFromEmpty = meshWasEmpty
if Date().timeIntervalSince(lastNetworkNotificationTime) >= cooldown { meshWasEmpty = false
recentlySeenPeers.formUnion(newPeers)
guard cameFromEmpty, !newPeers.isEmpty else { return }
if Date().timeIntervalSince(lastNetworkNotificationTime) >= notificationCooldownSeconds {
lastNetworkNotificationTime = Date() lastNetworkNotificationTime = Date()
context.notifyNetworkAvailable(peerCount: meshPeers.count) context.notifyNetworkAvailable(peerCount: meshPeers.count)
SecureLogger.info( SecureLogger.info(
@@ -185,6 +202,7 @@ private extension ChatPeerListCoordinator {
if activeMeshPeerCount == 0 { if activeMeshPeerCount == 0 {
recentlySeenPeers.removeAll() recentlySeenPeers.removeAll()
meshWasEmpty = true
SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session) SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session)
} else { } else {
SecureLogger.debug( SecureLogger.debug(
@@ -225,6 +243,7 @@ private extension ChatPeerListCoordinator {
if activeMeshPeerCount == 0 { if activeMeshPeerCount == 0 {
recentlySeenPeers.removeAll() recentlySeenPeers.removeAll()
meshWasEmpty = true
SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session) SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session)
} else { } else {
SecureLogger.debug( SecureLogger.debug(
@@ -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 { #Preview {
let keychain = PreviewKeychainManager() let keychain = PreviewKeychainManager()
let viewModel = ChatViewModel( let viewModel = ChatViewModel(
@@ -117,3 +121,4 @@ struct TextMessageView: View {
} }
.environmentObject(conversationUIModel) .environmentObject(conversationUIModel)
} }
#endif
@@ -201,6 +201,52 @@ struct ChatPeerListCoordinatorContextTests {
#expect(context.networkAvailableNotifications == [1]) #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 @Test @MainActor
func didUpdatePeerList_meshInactivePeersNeverNotify() async { func didUpdatePeerList_meshInactivePeersNeverNotify() async {
let context = MockChatPeerListContext() let context = MockChatPeerListContext()
@@ -110,7 +110,87 @@ struct BLEConnectionSchedulerTests {
} }
@Test @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<String>()
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<String>()
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<String>()
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<String>() let scheduler = BLEConnectionScheduler<String>()
let now = Date() let now = Date()
scheduler.recordConnectionTimeout(peripheralID: "p1", at: now) scheduler.recordConnectionTimeout(peripheralID: "p1", at: now)
@@ -123,8 +203,27 @@ struct BLEConnectionSchedulerTests {
now: now.addingTimeInterval(1) now: now.addingTimeInterval(1)
) )
#expect(threshold == TransportConfig.bleRSSIHighTimeoutThreshold) #expect(threshold == TransportConfig.bleDynamicRSSIThresholdDefault)
#expect(scheduler.dynamicRSSIThreshold == TransportConfig.bleRSSIHighTimeoutThreshold) }
@Test
func isolationRelaxesRSSIThresholdOverTime() {
let scheduler = BLEConnectionScheduler<String>()
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 @Test
@@ -78,6 +78,47 @@ struct BLEFanoutSelectorTests {
#expect(first.centralIDs.count == 4) #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 @Test
func broadcastWithTwoLinksKeepsBothAfterIngressExclusion() { func broadcastWithTwoLinksKeepsBothAfterIngressExclusion() {
let selection = BLEFanoutSelector.selectLinks( let selection = BLEFanoutSelector.selectLinks(
@@ -94,6 +94,46 @@ struct RelayControllerTests {
#expect(decision.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs) #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 @Test
func denseGraph_capsTTL() async { func denseGraph_capsTTL() async {
let decision = RelayController.decide( let decision = RelayController.decide(