mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:05:20 +00:00
* 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>
329 lines
13 KiB
Swift
329 lines
13 KiB
Swift
import BitLogger
|
|
import BitFoundation
|
|
import Foundation
|
|
import Combine
|
|
|
|
/// Manages persistent favorite relationships between peers
|
|
@MainActor
|
|
final class FavoritesPersistenceService: ObservableObject {
|
|
|
|
struct FavoriteRelationship: Codable {
|
|
let peerNoisePublicKey: Data
|
|
let peerNostrPublicKey: String?
|
|
let peerNickname: String
|
|
let isFavorite: Bool
|
|
let theyFavoritedUs: Bool
|
|
let favoritedAt: Date
|
|
let lastUpdated: Date
|
|
// Track what we last sent as OUR npub to this peer, to avoid resending unless it changes
|
|
// Note: we do not track which npub we last sent to them; sending happens only on favorite toggle
|
|
|
|
var isMutual: Bool {
|
|
isFavorite && theyFavoritedUs
|
|
}
|
|
}
|
|
|
|
// We intentionally do not track when we last sent our npub; sending happens only on favorite toggle.
|
|
|
|
private static let storageKey = "chat.bitchat.favorites"
|
|
private static let keychainService = "chat.bitchat.favorites"
|
|
private let keychain: KeychainManagerProtocol
|
|
|
|
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
|
@Published private(set) var mutualFavorites: Set<Data> = []
|
|
|
|
static let shared = FavoritesPersistenceService()
|
|
|
|
/// Default keychain for the `shared` singleton. Under test this is an
|
|
/// in-memory keychain so touching `shared` never blocks on securityd
|
|
/// (`SecItemCopyMatching` can hang in test environments) and never reads
|
|
/// or writes the developer's real keychain. Production behavior is
|
|
/// unchanged. Tests that need their own instance keep injecting a mock
|
|
/// via `init(keychain:)`.
|
|
private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol {
|
|
// 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()) {
|
|
self.keychain = keychain
|
|
loadFavorites()
|
|
|
|
// Update mutual favorites when favorites change
|
|
$favorites
|
|
.map { favorites in
|
|
Set(favorites.compactMap { $0.value.isMutual ? $0.key : nil })
|
|
}
|
|
.assign(to: &$mutualFavorites)
|
|
}
|
|
|
|
/// Add or update a favorite
|
|
func addFavorite(
|
|
peerNoisePublicKey: Data,
|
|
peerNostrPublicKey: String? = nil,
|
|
peerNickname: String
|
|
) {
|
|
SecureLogger.info("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
|
|
|
|
let existing = favorites[peerNoisePublicKey]
|
|
|
|
let relationship = FavoriteRelationship(
|
|
peerNoisePublicKey: peerNoisePublicKey,
|
|
peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey,
|
|
peerNickname: peerNickname,
|
|
isFavorite: true,
|
|
theyFavoritedUs: existing?.theyFavoritedUs ?? false,
|
|
favoritedAt: existing?.favoritedAt ?? Date(),
|
|
lastUpdated: Date()
|
|
)
|
|
|
|
// Log if this creates a mutual favorite
|
|
if relationship.isMutual {
|
|
SecureLogger.info("💕 Mutual favorite relationship established with \(peerNickname)!", category: .session)
|
|
}
|
|
|
|
favorites[peerNoisePublicKey] = relationship
|
|
saveFavorites()
|
|
|
|
// Notify observers
|
|
NotificationCenter.default.post(
|
|
name: .favoriteStatusChanged,
|
|
object: nil,
|
|
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
|
)
|
|
}
|
|
|
|
/// Remove a favorite
|
|
func removeFavorite(peerNoisePublicKey: Data) {
|
|
guard let existing = favorites[peerNoisePublicKey] else { return }
|
|
|
|
SecureLogger.info("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
|
|
|
|
// If they still favorite us, keep the record but mark us as not favoriting
|
|
if existing.theyFavoritedUs {
|
|
let updated = FavoriteRelationship(
|
|
peerNoisePublicKey: existing.peerNoisePublicKey,
|
|
peerNostrPublicKey: existing.peerNostrPublicKey,
|
|
peerNickname: existing.peerNickname,
|
|
isFavorite: false,
|
|
theyFavoritedUs: true,
|
|
favoritedAt: existing.favoritedAt,
|
|
lastUpdated: Date()
|
|
)
|
|
favorites[peerNoisePublicKey] = updated
|
|
// Keeping record - they still favorite us
|
|
} else {
|
|
// Neither side favorites, remove completely
|
|
favorites.removeValue(forKey: peerNoisePublicKey)
|
|
// Completely removed from favorites
|
|
}
|
|
|
|
saveFavorites()
|
|
|
|
// Notify observers
|
|
NotificationCenter.default.post(
|
|
name: .favoriteStatusChanged,
|
|
object: nil,
|
|
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
|
)
|
|
}
|
|
|
|
/// Update when we learn a peer favorited/unfavorited us
|
|
func updatePeerFavoritedUs(
|
|
peerNoisePublicKey: Data,
|
|
favorited: Bool,
|
|
peerNickname: String? = nil,
|
|
peerNostrPublicKey: String? = nil
|
|
) {
|
|
let existing = favorites[peerNoisePublicKey]
|
|
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
|
|
|
|
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
|
|
|
|
let relationship = FavoriteRelationship(
|
|
peerNoisePublicKey: peerNoisePublicKey,
|
|
peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey,
|
|
peerNickname: displayName,
|
|
isFavorite: existing?.isFavorite ?? false,
|
|
theyFavoritedUs: favorited,
|
|
favoritedAt: existing?.favoritedAt ?? Date(),
|
|
lastUpdated: Date()
|
|
)
|
|
|
|
if !relationship.isFavorite && !relationship.theyFavoritedUs {
|
|
// Neither side favorites, remove completely
|
|
favorites.removeValue(forKey: peerNoisePublicKey)
|
|
// Removed - neither side favorites anymore
|
|
} else {
|
|
favorites[peerNoisePublicKey] = relationship
|
|
|
|
// Check if this creates a mutual favorite
|
|
if relationship.isMutual {
|
|
SecureLogger.info("💕 Mutual favorite relationship established with \(displayName)!", category: .session)
|
|
}
|
|
}
|
|
|
|
saveFavorites()
|
|
|
|
// Notify observers
|
|
NotificationCenter.default.post(
|
|
name: .favoriteStatusChanged,
|
|
object: nil,
|
|
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
|
)
|
|
}
|
|
|
|
/// Check if a peer is favorited by us
|
|
func isFavorite(_ peerNoisePublicKey: Data) -> Bool {
|
|
favorites[peerNoisePublicKey]?.isFavorite ?? false
|
|
}
|
|
|
|
/// Check if we have a mutual favorite relationship
|
|
func isMutualFavorite(_ peerNoisePublicKey: Data) -> Bool {
|
|
favorites[peerNoisePublicKey]?.isMutual ?? false
|
|
}
|
|
|
|
/// Get favorite status for a peer
|
|
func getFavoriteStatus(for peerNoisePublicKey: Data) -> FavoriteRelationship? {
|
|
favorites[peerNoisePublicKey]
|
|
}
|
|
|
|
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
|
/// Falls back to scanning favorites and matching on derived peer ID.
|
|
func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {
|
|
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
|
guard peerID.isShort else { return nil }
|
|
for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
|
|
return rel
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Clear all favorites - used for panic mode
|
|
func clearAllFavorites() {
|
|
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
|
|
|
favorites.removeAll()
|
|
saveFavorites()
|
|
|
|
// Delete from keychain directly
|
|
keychain.delete(
|
|
key: Self.storageKey,
|
|
service: Self.keychainService
|
|
)
|
|
|
|
// Post notification for UI update
|
|
NotificationCenter.default.post(name: .favoriteStatusChanged, object: nil)
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
private func saveFavorites() {
|
|
let relationships = Array(favorites.values)
|
|
// Saving favorite relationships to keychain
|
|
|
|
do {
|
|
let encoder = JSONEncoder()
|
|
let data = try encoder.encode(relationships)
|
|
|
|
// Store in keychain for security
|
|
keychain.save(
|
|
key: Self.storageKey,
|
|
data: data,
|
|
service: Self.keychainService,
|
|
accessible: nil
|
|
)
|
|
|
|
// Successfully saved favorites
|
|
} catch {
|
|
SecureLogger.error("Failed to save favorites: \(error)", category: .session)
|
|
}
|
|
}
|
|
|
|
private func loadFavorites() {
|
|
// Loading favorites from keychain
|
|
|
|
guard let data = keychain.load(
|
|
key: Self.storageKey,
|
|
service: Self.keychainService
|
|
) else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
let decoder = JSONDecoder()
|
|
let relationships = try decoder.decode([FavoriteRelationship].self, from: data)
|
|
|
|
SecureLogger.info("✅ Loaded \(relationships.count) favorite relationships", category: .session)
|
|
|
|
// Log Nostr public key info
|
|
for relationship in relationships {
|
|
if relationship.peerNostrPublicKey == nil {
|
|
SecureLogger.warning("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", category: .session)
|
|
}
|
|
}
|
|
|
|
// Convert to dictionary, cleaning up duplicates by public key (not nickname)
|
|
var seenPublicKeys: [Data: FavoriteRelationship] = [:]
|
|
var cleanedRelationships: [FavoriteRelationship] = []
|
|
|
|
for relationship in relationships {
|
|
// Check for duplicates by public key (the actual unique identifier)
|
|
if let existing = seenPublicKeys[relationship.peerNoisePublicKey] {
|
|
SecureLogger.warning("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", category: .session)
|
|
|
|
// Keep the most recent or most complete relationship
|
|
if relationship.lastUpdated > existing.lastUpdated ||
|
|
(relationship.peerNostrPublicKey != nil && existing.peerNostrPublicKey == nil) {
|
|
// Replace with newer/more complete entry
|
|
seenPublicKeys[relationship.peerNoisePublicKey] = relationship
|
|
cleanedRelationships.removeAll { $0.peerNoisePublicKey == relationship.peerNoisePublicKey }
|
|
cleanedRelationships.append(relationship)
|
|
}
|
|
} else {
|
|
seenPublicKeys[relationship.peerNoisePublicKey] = relationship
|
|
cleanedRelationships.append(relationship)
|
|
}
|
|
}
|
|
|
|
// If we cleaned up duplicates, save the cleaned list
|
|
if cleanedRelationships.count < relationships.count {
|
|
// Cleaned up duplicates
|
|
|
|
// Clear and rebuild favorites dictionary
|
|
favorites.removeAll()
|
|
for relationship in cleanedRelationships {
|
|
favorites[relationship.peerNoisePublicKey] = relationship
|
|
}
|
|
|
|
// Save cleaned favorites
|
|
saveFavorites()
|
|
|
|
// Notify that favorites have been cleaned up (synchronously since we're already on main actor)
|
|
NotificationCenter.default.post(name: .favoriteStatusChanged, object: nil)
|
|
} else {
|
|
// No duplicates, just populate normally
|
|
for relationship in cleanedRelationships {
|
|
favorites[relationship.peerNoisePublicKey] = relationship
|
|
}
|
|
}
|
|
|
|
// Log loaded relationships
|
|
// Loaded relationships successfully
|
|
} catch {
|
|
SecureLogger.error("Failed to load favorites: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Notification Names
|
|
|
|
extension Notification.Name {
|
|
static let favoriteStatusChanged = Notification.Name("FavoriteStatusChanged")
|
|
}
|