Compare commits

..
Author SHA1 Message Date
jack ed105f311a refactor(router): make MessageRouter depend on NostrOutboxService; update ChatViewModel wiring 2025-08-26 21:27:55 +02:00
jack 8ebb7411a7 fix(nostr-inbox): initialize NostrInboxService inside @MainActor initializers to satisfy actor isolation 2025-08-26 21:20:51 +02:00
jack fb60c929eb refactor(nostr-outbox): add NostrOutboxService and migrate ChatViewModel geohash PM/ACK sends to it
Centralize Nostr geohash outbox (PM, DELIVERED, READ) into a service wrapping NostrTransport; inject into ChatViewModel and remove ad-hoc NostrTransport instantiations.
2025-08-26 21:19:49 +02:00
jack 678795adcf fix(nostr): mark NostrInboxService @MainActor and use NostrEvent type; resolve Swift 6 actor isolation errors 2025-08-26 21:13:40 +02:00
jack 37e973bf9f refactor(nostr): add NostrInboxService and route geohash/DM subscriptions through it; move de-dup away from ChatViewModel
- Introduce Services/NostrInboxService to centralize subscribe/unsubscribe + event de-dup.\n- Wire ChatViewModel resubscribeCurrentGeohash, switchLocationChannel, sampling, and global DM subscribe to use the service.\n- Remove ChatViewModel's local processedNostrEvents tracking.
2025-08-26 21:01:13 +02:00
jack 8c002b8942 fix(init): initialize autocompleteService in DI initializer to satisfy Swift init rules 2025-08-26 20:15:03 +02:00
jack 6533629362 refactor(init): add dependency-injected initializer for ChatViewModel to enable composition/testing
Provide an alternate initializer that accepts meshService, router, privateChatManager, unifiedPeerService, commandProcessor, and an optional ReadReceiptTracker. Keeps default init intact; both perform the same wiring and startup behavior.
2025-08-26 20:11:59 +02:00
jack dd4e2f6e86 refactor(hex+fingerprint): centralize SHA256 fingerprint helper; reuse PeerIDResolver in ID checks
- Move Data.sha256Fingerprint() to BinaryEncodingUtils for reuse; refactor NoiseEncryptionService to use it.\n- Replace manual short/full hex checks with PeerIDResolver in ChatViewModel, ContentView, and SecureIdentityStateManager.\n- Keep behavior identical; reduce ad-hoc string length/hex parsing.
2025-08-26 19:47:51 +02:00
jack 55187ac5c6 refactor(read-receipts): add ReadReceiptTracker and unify across VM + manager
Introduce Services/ReadReceiptTracker as single source of truth with persistence. Wire into ChatViewModel and PrivateChatManager, replacing duplicated sentReadReceipts sets and persistence logic. Add to Xcode targets (iOS/macOS).
2025-08-26 18:50:52 +02:00
d7b7f1f673 UI: diversify peer colors; smarter geo notifications; 21m live location (#531)
- Use minimal-distance hue palette for mesh and geohash lists; align chat sender colors with list palette.
- Add foreground geo notifications for different channels; deep-link to bitchat://geohash/<gh>; per-geohash 60s cooldown; respect self/blocks.
- Global geohash sampling runs outside the sheet; delegate handles deeplinks and suppresses when already in-channel.
- Switch location channel sheet to continuous CoreLocation with 21m distance filter; add config knob.
- Only link geohash hashtags when standalone (no @name#abcd or word#abcd).
- Include mesh-reachable peers in mesh counts and in "bitchatters nearby" notification.
- Add TransportConfig knobs for palette and geo notifications.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-26 18:43:22 +02:00
JDandGitHub 7aa3622349 Update README to explain hybrid messaging architecture (#530) 2025-08-26 18:42:21 +02:00
96c6fc0c0d Improve mesh relaying, presence, and DM robustness; signed public msgs; reachability UI; quicker announces; store-and-forward (#527)
* Sign public broadcasts; verify relayed messages via persisted signing keys; keep scheduled relays in sparse graphs and speed their jitter; persist announce signing key for offline auth; add short backoff after disconnect errors to reduce reconnect thrash

* Add connected vs reachable model: retain peers after link drop, expire after reachability window; expose all peers in snapshots; compute isReachable in UI; add meshReachable state and sorting; avoid removing peers on link events; notify UI on stale removals

* ContentView: handle new .meshReachable connection state in header icon switch (exhaustive switch fix)

* Logs: tag relayed announces as 'Reachable via mesh' and annotate public message logs with (direct|mesh) path for easier field analysis

* Fix syntax error: remove stray else/log inserted into writeOrEnqueue; keep logs clean

* UI: use 'point.3.connected.trianglepath.dotted' for mesh-reachable; change people count to include connected+reachable (exclude Nostr-only)

* UI: switch to 'point.3.filled.connected.trianglepath.dotted' for mesh-reachable icons in list and header

* Reachability: reduce retention to 21s for all peers (verified and unverified) to minimize stale presence

* mesh DMs/acks: route to reachable peers; queue READ/DELIVERED until handshake; add Transport.isPeerReachable; UI: hide offline non-mutuals; DM header: better name fallback + show transport + encryption icons; fix NostrTransport conformance

* Verification sheet: compute encryption status and fingerprint using short mesh ID mapping (fix 'not encrypted/handshake' for DMs with stable key)

* Announce cadence: faster discovery (4s), sparse 15±4s, dense 30±8s; initial 0.6s; post-subscribe 50ms; min-force 150ms; maintenance 5s; proactive announces on handshake + recent-traffic nudge

* Relay: increase broadcast TTL cap in sparse graphs to 6; tighten jitter for handshake (10–35ms) and directed (20–60ms) relays

* Range/robustness: store-and-forward for directed packets (15s) with flush on new links + periodic; announces: no subset + afterglow re-announce on first-seen; adaptive scanning: force ON when <=2 neighbors or recent traffic

* Fix warnings: remove unused msgID and unused mutable var in directed spool flush

* Announces: TTL 7 (sparse only) via RelayController; no fanout subset for announces; neighbor-change rebroadcast of last 2–3 announces. Fragments: faster pacing (5ms global, 4ms directed).

* Peer list: real-time icon updates by publishing snapshots on connectivity checks; add unread message indicator (envelope) next to peers with unread DMs

* UI: unread envelope uses orange; hasUnreadMessages checks Nostr conv key for peers with known Nostr pubkeys (geohash DM consistency)

* Logs/robustness: debounce disconnect notifications (1.5s), debounce 'reconnected' logs (2s), add weak-link cooldown after timeouts on very weak RSSI (<= -90)

* Peer icons: faster, accurate reachability\n\n- Run connectivity checks every maintenance tick (5s)\n- Publish peer snapshots on central unsubscribe for instant UI refresh\n- Lower inactivity timeout to 8s and disconnect debounce to 0.9s\n- Gate reachability on mesh-attached (>=1 direct link); no links => no reachable peers\n- Keep 21s retention for verified/unverified, but only when attached to mesh\n\nImproves list responsiveness when walking out of range and prevents stale 'reachable' states when isolated.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-26 17:28:08 +02:00
a7d5b2d7d9 Centralize remaining magic numbers; finalize logging hygiene across UI/BLE/Nostr (#522)
* docs(plans): add refactor plan; chore(config): introduce TransportConfig and use in BLEService/ChatViewModel/PrivateChatManager; feat: add PeerDisplayNameResolver and apply in BLEService; chore: make TransportPeerSnapshot Equatable/Hashable; perf: simplify read-receipt persistence (no synchronize)

* project: add TransportConfig.swift and PeerDisplayNameResolver.swift to iOS/macOS targets (no xcodegen)

* chore: remove unnecessary UserDefaults.synchronize() calls (extension/app/VM)

* project: fix TransportConfig reference path; remove recovered reference; hook correct fileRef in iOS/macOS sources

* chore(BLE): move connect/duty/announce constants to TransportConfig and reference them

* chore(NostrTransport): factor recipient npub resolution into helper; reduce duplication

* chore(BLE): trim raw hex dump on central decode failure to length+prefix

* project: remove duplicate TransportConfig.swift entries from Sources build phases

* chore: centralize more constants in TransportConfig (BLE thresholds, Nostr read-ack, UI caps) and adopt in BLEService/ChatViewModel/NostrTransport

* chore: centralize location + geohash constants (filters, lookback, relay count) and adopt in LocationChannelManager/ChatViewModel

* chore: centralize compression, dedup, verification QR, relay backoff, georelay fetch constants; adopt across modules

* chore: centralize more BLE/Nostr delays; tighten NostrRelayManager logs to concise summaries; adopt config for location/geohash/relays

* refactor(config): centralize remaining magic numbers and finalize log hygiene

Add comprehensive TransportConfig constants for UI, Nostr, and BLE; adopt across ChatViewModel, ContentView, BLEService, ShareViewController, and BitchatApp to remove scattered literals. Standardize Nostr lookbacks/limits, UI delays/animations, and BLE announce/duty-cycle/candidate caps. Preserve behavior while making tuning explicit and safe.

Highlights:\n- UI: animations, scroll throttle, long-message thresholds, batch stagger, color hue tuning, rate-limit buckets, read-receipt debounce, startup delays, share accept/dismiss windows, migration cutoff.\n- Nostr: short display length (8), conv-key prefix length (16), DM lookback (24h), geohash sample lookback/limits; consistent use throughout ChatViewModel.\n- BLE: dynamic RSSI defaults, announce intervals/base+jitter, duty cycles (dense/sparse), fragment/ingress lifetimes, expected write timings/spacing, recent packet window (30s/100), peer inactivity timeout; unified candidate caps (100).\n- Share: use constant dismiss delay; App: use constant share accept window.\n\nRisk/impact: behavior-equivalent with centralized knobs; easier to tune without code edits.

* project: add TransportConfig.swift to Share Extension target to fix build

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-25 21:18:51 +02:00
27 changed files with 1770 additions and 438 deletions
+68 -18
View File
@@ -2,59 +2,108 @@
## bitchat ## bitchat
A decentralized peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required, no servers, no phone numbers. It's the side-groupchat. A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers. It's the side-groupchat.
[bitchat.free](http://bitchat.free) [bitchat.free](http://bitchat.free)
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622) 📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
> [!WARNING] > [!WARNING]
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns. > Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
## License ## License
This project is released into the public domain. See the [LICENSE](LICENSE) file for details. This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
## Features ## Features
- **Dual Transport Architecture**: Bluetooth mesh for offline + Nostr protocol for internet-based messaging
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE - **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers - **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) - **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface - **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
- **Universal App**: Native support for iOS and macOS - **Universal App**: Native support for iOS and macOS
- **Emergency Wipe**: Triple-tap to instantly clear all data - **Emergency Wipe**: Triple-tap to instantly clear all data
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking - **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat) ## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)
### Binary Protocol BitChat uses a **hybrid messaging architecture** with two complementary transport layers:
bitchat uses an efficient binary protocol optimized for Bluetooth LE:
- Compact packet format with 1-byte type field
- TTL-based message routing (max 7 hops)
- Automatic fragmentation for large messages
- Message deduplication via unique IDs
### Mesh Networking ### Bluetooth Mesh Network (Offline)
- Each device acts as both client and peripheral
- Automatic peer discovery and connection management - **Local Communication**: Direct peer-to-peer within Bluetooth range
- Adaptive duty cycling for battery optimization - **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
- **No Internet Required**: Works completely offline in disaster scenarios
- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy
- **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints
- **Automatic Discovery**: Peer discovery and connection management
- **Adaptive Power**: Battery-optimized duty cycling
### Nostr Protocol (Internet)
- **Global Reach**: Connect with users worldwide via internet relays
- **Location Channels**: Geographic chat rooms using geohash coordinates
- **290+ Relay Network**: Distributed across the globe for reliability
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
### Channel Types
#### `mesh #bluetooth`
- **Transport**: Bluetooth Low Energy mesh network
- **Scope**: Local devices within multi-hop range
- **Internet**: Not required
- **Use Case**: Offline communication, protests, disasters, remote areas
#### Location Channels (`block #dr5rsj7`, `neighborhood #dr5rs`, `country #dr`)
- **Transport**: Nostr protocol over internet
- **Scope**: Geographic areas defined by geohash precision
- `block` (7 chars): City block level
- `neighborhood` (6 chars): District/neighborhood
- `city` (5 chars): City level
- `province` (4 chars): State/province
- `region` (2 chars): Country/large region
- **Internet**: Required (connects to Nostr relays)
- **Use Case**: Location-based community chat, local events, regional discussions
### Direct Message Routing
Private messages use **intelligent transport selection**:
1. **Bluetooth First** (preferred when available)
- Direct connection with established Noise session
- Fastest and most private option
2. **Nostr Fallback** (when Bluetooth unavailable)
- Uses recipient's Nostr public key
- NIP-17 gift-wrapping for privacy
- Routes through global relay network
3. **Smart Queuing** (when neither available)
- Messages queued until transport becomes available
- Automatic delivery when connection established
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md). For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).
## Setup ## Setup
### Option 1: Using XcodeGen (Recommended) ### Option 1: Using XcodeGen (Recommended)
1. Install XcodeGen if you haven't already: 1. Install XcodeGen if you haven't already:
```bash ```bash
brew install xcodegen brew install xcodegen
``` ```
2. Generate the Xcode project: 2. Generate the Xcode project:
```bash ```bash
cd bitchat cd bitchat
xcodegen generate xcodegen generate
@@ -68,6 +117,7 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
### Option 2: Using Swift Package Manager ### Option 2: Using Swift Package Manager
1. Open the project in Xcode: 1. Open the project in Xcode:
```bash ```bash
cd bitchat cd bitchat
open Package.swift open Package.swift
@@ -84,5 +134,5 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
### Option 4: just ### Option 4: just
Want to try this on macos: `just run` will set it up and run from source. Want to try this on macos: `just run` will set it up and run from source.
Run `just clean` afterwards to restore things to original state for mobile app building and development. Run `just clean` afterwards to restore things to original state for mobile app building and development.
+24 -9
View File
@@ -56,6 +56,12 @@
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; }; 049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B12E51F319001A566B /* NostrTransport.swift */; }; 049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B12E51F319001A566B /* NostrTransport.swift */; };
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; }; 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; };
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; };
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; };
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; };
ABCD00082E5CCCC300162C4E /* NostrOutboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */; };
ABCD00092E5CCCC300162C4F /* NostrOutboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */; };
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; }; 0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; 0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; }; 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
@@ -213,6 +219,9 @@
049BD39E2E51DBF4001A566B /* Packets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Packets.swift; sourceTree = "<group>"; }; 049BD39E2E51DBF4001A566B /* Packets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Packets.swift; sourceTree = "<group>"; };
049BD39F2E51DBF4001A566B /* PeerID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerID.swift; sourceTree = "<group>"; }; 049BD39F2E51DBF4001A566B /* PeerID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerID.swift; sourceTree = "<group>"; };
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = "<group>"; }; 049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = "<group>"; };
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadReceiptTracker.swift; sourceTree = "<group>"; };
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrInboxService.swift; sourceTree = "<group>"; };
ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrOutboxService.swift; sourceTree = "<group>"; };
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = "<group>"; }; 049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = "<group>"; };
049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = "<group>"; }; 049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = "<group>"; };
049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = "<group>"; }; 049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = "<group>"; };
@@ -328,7 +337,6 @@
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */, A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
C3D98EB3E1B455E321F519F4 /* bitchatTests */, C3D98EB3E1B455E321F519F4 /* bitchatTests */,
9F37F9F2C353B58AC809E93B /* Products */, 9F37F9F2C353B58AC809E93B /* Products */,
048A4BE52E5CCC5C00162C4A /* Recovered References */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -521,6 +529,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */, 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */,
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */,
ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */,
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */, AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */, 047502B82E560F690083520F /* RelayController.swift */,
0475028B2E54171C0083520F /* LocationChannelManager.swift */, 0475028B2E54171C0083520F /* LocationChannelManager.swift */,
@@ -744,13 +755,15 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */,
ABCD00082E5CCCC300162C4E /* NostrOutboxService.swift in Sources */,
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */, 1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */, AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */, AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */, A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */, AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */, 9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */, 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */,
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */, 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
@@ -804,13 +817,15 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */,
ABCD00092E5CCCC300162C4F /* NostrOutboxService.swift in Sources */,
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */, 1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */, AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */, AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */, A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */, ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */, AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */, 049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */,
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */, F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
@@ -1007,7 +1022,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.3.2; MARKETING_VERSION = 1.3.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -1038,7 +1053,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.2; MARKETING_VERSION = 1.3.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1093,7 +1108,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.2; MARKETING_VERSION = 1.3.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1125,7 +1140,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.3.1; MARKETING_VERSION = 1.3.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -1214,7 +1229,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.3.1; MARKETING_VERSION = 1.3.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -1307,7 +1322,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.3.2; MARKETING_VERSION = 1.3.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+17
View File
@@ -173,6 +173,14 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
} }
} }
} }
// Handle deeplink (e.g., geohash activity)
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
#if os(iOS)
DispatchQueue.main.async { UIApplication.shared.open(url) }
#else
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
#endif
}
completionHandler() completionHandler()
} }
@@ -192,6 +200,15 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
} }
} }
} }
// Suppress geohash activity notification if we're already in that geohash channel
if identifier.hasPrefix("geo-activity-"),
let deep = userInfo["deeplink"] as? String,
let gh = deep.components(separatedBy: "/").last {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
completionHandler([])
return
}
}
// Show notification in all other cases // Show notification in all other cases
completionHandler([.banner, .sound]) completionHandler([.banner, .sound])
+2
View File
@@ -106,6 +106,8 @@ enum HandshakeState {
struct CryptographicIdentity: Codable { struct CryptographicIdentity: Codable {
let fingerprint: String // SHA256 of public key let fingerprint: String // SHA256 of public key
let publicKey: Data // Noise static public key let publicKey: Data // Noise static public key
// Optional Ed25519 signing public key (used to authenticate public messages)
var signingPublicKey: Data? = nil
let firstSeen: Date let firstSeen: Date
let lastHandshake: Date? let lastHandshake: Date?
} }
@@ -229,6 +229,91 @@ class SecureIdentityStateManager {
return cache.socialIdentities[fingerprint] return cache.socialIdentities[fingerprint]
} }
} }
// MARK: - Cryptographic Identities
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
/// - Parameters:
/// - fingerprint: SHA-256 hex of the Noise static public key
/// - noisePublicKey: Noise static public key data
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
queue.async(flags: .barrier) {
let now = Date()
if var existing = self.cryptographicIdentities[fingerprint] {
// Update keys if changed
if existing.publicKey != noisePublicKey {
existing = CryptographicIdentity(
fingerprint: fingerprint,
publicKey: noisePublicKey,
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = existing
} else {
// Update signing key and lastHandshake
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
let updated = CryptographicIdentity(
fingerprint: existing.fingerprint,
publicKey: existing.publicKey,
signingPublicKey: existing.signingPublicKey,
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = updated
}
// Persist updated state (already assigned in branches above)
} else {
// New entry
let entry = CryptographicIdentity(
fingerprint: fingerprint,
publicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
firstSeen: now,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = entry
}
// Optionally persist claimed nickname into social identity
if let claimed = claimedNickname {
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: claimed,
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
// Update claimed nickname if changed
if identity.claimedNickname != claimed {
identity.claimedNickname = claimed
self.cache.socialIdentities[fingerprint] = identity
} else if self.cache.socialIdentities[fingerprint] == nil {
self.cache.socialIdentities[fingerprint] = identity
}
}
self.saveIdentityCache()
}
}
/// Retrieve cryptographic identity by fingerprint
func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? {
queue.sync { cryptographicIdentities[fingerprint] }
}
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
queue.sync {
// Defensive: ensure hex and correct length
guard PeerIDResolver.isShortID(peerID) else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
}
}
func getAllSocialIdentities() -> [SocialIdentity] { func getAllSocialIdentities() -> [SocialIdentity] {
queue.sync { queue.sync {
+9 -1
View File
@@ -8,6 +8,7 @@ struct BitchatPeer: Identifiable, Equatable {
let nickname: String let nickname: String
let lastSeen: Date let lastSeen: Date
let isConnected: Bool let isConnected: Bool
let isReachable: Bool
// Favorite-related properties // Favorite-related properties
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship? var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
@@ -18,6 +19,7 @@ struct BitchatPeer: Identifiable, Equatable {
// Connection state // Connection state
enum ConnectionState { enum ConnectionState {
case bluetoothConnected case bluetoothConnected
case meshReachable // Seen via mesh recently, not directly connected
case nostrAvailable // Mutual favorite, reachable via Nostr case nostrAvailable // Mutual favorite, reachable via Nostr
case offline // Not connected via any transport case offline // Not connected via any transport
} }
@@ -25,6 +27,8 @@ struct BitchatPeer: Identifiable, Equatable {
var connectionState: ConnectionState { var connectionState: ConnectionState {
if isConnected { if isConnected {
return .bluetoothConnected return .bluetoothConnected
} else if isReachable {
return .meshReachable
} else if favoriteStatus?.isMutual == true { } else if favoriteStatus?.isMutual == true {
// Mutual favorites can communicate via Nostr when offline // Mutual favorites can communicate via Nostr when offline
return .nostrAvailable return .nostrAvailable
@@ -54,6 +58,8 @@ struct BitchatPeer: Identifiable, Equatable {
switch connectionState { switch connectionState {
case .bluetoothConnected: case .bluetoothConnected:
return "📻" // Radio icon for mesh connection return "📻" // Radio icon for mesh connection
case .meshReachable:
return "📡" // Antenna for mesh reachable
case .nostrAvailable: case .nostrAvailable:
return "🌐" // Purple globe for Nostr return "🌐" // Purple globe for Nostr
case .offline: case .offline:
@@ -71,13 +77,15 @@ struct BitchatPeer: Identifiable, Equatable {
noisePublicKey: Data, noisePublicKey: Data,
nickname: String, nickname: String,
lastSeen: Date = Date(), lastSeen: Date = Date(),
isConnected: Bool = false isConnected: Bool = false,
isReachable: Bool = false
) { ) {
self.id = id self.id = id
self.noisePublicKey = noisePublicKey self.noisePublicKey = noisePublicKey
self.nickname = nickname self.nickname = nickname
self.lastSeen = lastSeen self.lastSeen = lastSeen
self.isConnected = isConnected self.isConnected = isConnected
self.isReachable = isReachable
// Load favorite status - will be set later by the manager // Load favorite status - will be set later by the manager
self.favoriteStatus = nil self.favoriteStatus = nil
+11 -1
View File
@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import CryptoKit
// MARK: - Hex Encoding/Decoding // MARK: - Hex Encoding/Decoding
@@ -35,6 +36,16 @@ extension Data {
} }
} }
// MARK: - Fingerprint Helpers
extension Data {
/// SHA-256 over data, hex-encoded (lowercase)
func sha256Fingerprint() -> String {
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - Binary Encoding Utilities // MARK: - Binary Encoding Utilities
extension Data { extension Data {
@@ -220,4 +231,3 @@ extension Data {
return data return data
} }
} }
+430 -124
View File
@@ -102,6 +102,12 @@ final class BLEService: NSObject {
// Queue for messages pending handshake completion // Queue for messages pending handshake completion
private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:] private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:]
// Noise typed payloads (ACKs, read receipts, etc.) pending handshake
private var pendingNoisePayloadsAfterHandshake: [String: [Data]] = [:]
// Keep a tiny buffer of the last few unique announces we've seen (by sender)
private var recentAnnounceBySender: [String: BitchatPacket] = [:]
private var recentAnnounceOrder: [String] = []
private let recentAnnounceBufferCap = 3
// Queue for notifications that failed due to full queue // Queue for notifications that failed due to full queue
private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = [] private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = []
@@ -122,6 +128,13 @@ final class BLEService: NSObject {
// Backpressure-aware write queue per peripheral // Backpressure-aware write queue per peripheral
private var pendingPeripheralWrites: [String: [Data]] = [:] private var pendingPeripheralWrites: [String: [Data]] = [:]
// Debounce duplicate disconnect notifies
private var recentDisconnectNotifies: [String: Date] = [:]
// Store-and-forward for directed messages when we have no links
// Keyed by recipient short peerID -> messageID -> (packet, enqueuedAt)
private var pendingDirectedRelays: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
// Debounce for 'reconnected' logs
private var lastReconnectLogAt: [String: Date] = [:]
// MARK: - Maintenance Timer // MARK: - Maintenance Timer
@@ -307,6 +320,11 @@ final class BLEService: NSObject {
// Send any messages that were queued during handshake // Send any messages that were queued during handshake
self?.messageQueue.async { [weak self] in self?.messageQueue.async { [weak self] in
self?.sendPendingMessagesAfterHandshake(for: peerID) self?.sendPendingMessagesAfterHandshake(for: peerID)
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
}
// Proactive presence nudge: announce immediately after handshake
self?.messageQueue.async { [weak self] in
self?.sendAnnounce(forceSend: true)
} }
} }
@@ -494,7 +512,7 @@ final class BLEService: NSObject {
func isPeerConnected(_ peerID: String) -> Bool { func isPeerConnected(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys // Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = { let shortID: String = {
if peerID.count == 64, let key = Data(hexString: peerID) { if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key) return PeerIDUtils.derivePeerID(fromPublicKey: key)
} }
return peerID return peerID
@@ -502,6 +520,27 @@ final class BLEService: NSObject {
return collectionsQueue.sync { peers[shortID]?.isConnected ?? false } return collectionsQueue.sync { peers[shortID]?.isConnected ?? false }
} }
func isPeerReachable(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = {
if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key)
}
return peerID
}()
return collectionsQueue.sync {
// Must be mesh-attached: at least one live direct link to the mesh
let meshAttached = peers.values.contains { $0.isConnected }
guard let info = peers[shortID] else { return false }
if info.isConnected { return true }
guard meshAttached else { return false }
// Apply reachability retention window
let isVerified = info.isVerifiedNickname
let retention: TimeInterval = isVerified ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
return Date().timeIntervalSince(info.lastSeen) <= retention
}
}
func peerNickname(peerID: String) -> String? { func peerNickname(peerID: String) -> String? {
collectionsQueue.sync { collectionsQueue.sync {
guard let peer = peers[peerID], peer.isConnected else { return nil } guard let peer = peers[peerID], peer.isConnected else { return nil }
@@ -541,43 +580,41 @@ final class BLEService: NSObject {
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
// Send encrypted read receipt // Create typed payload: [type byte] + [message ID]
guard noiseService.hasSession(with: peerID) else { var payload = Data([NoisePayloadType.readReceipt.rawValue])
SecureLogger.log("Cannot send read receipt - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning) payload.append(contentsOf: receipt.originalMessageID.utf8)
return
} if noiseService.hasEstablishedSession(with: peerID) {
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)",
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: SecureLogger.session, level: .debug)
category: SecureLogger.session, level: .debug) do {
let encrypted = try noiseService.encrypt(payload, for: peerID)
// Create read receipt payload: [type byte] + [message ID] let packet = BitchatPacket(
var receiptPayload = Data([NoisePayloadType.readReceipt.rawValue]) type: MessageType.noiseEncrypted.rawValue,
receiptPayload.append(contentsOf: receipt.originalMessageID.utf8) senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID),
do { timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
let encrypted = try noiseService.encrypt(receiptPayload, for: peerID) payload: encrypted,
let packet = BitchatPacket( signature: nil,
type: MessageType.noiseEncrypted.rawValue, ttl: messageTTL
senderID: Data(hexString: myPeerID) ?? Data(), )
recipientID: Data(hexString: peerID), if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), broadcastPacket(packet)
payload: encrypted, } else {
signature: nil, messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
ttl: messageTTL
)
// If already on messageQueue, call directly
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet)
} else {
messageQueue.async { [weak self] in
self?.broadcastPacket(packet)
} }
} catch {
SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error)
} }
} else {
// Read receipt sent // Queue for after handshake and initiate if needed
} catch { collectionsQueue.async(flags: .barrier) { [weak self] in
SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error) guard let self = self else { return }
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
}
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
SecureLogger.log("🕒 Queued READ receipt for \(peerID) until handshake completes",
category: SecureLogger.session, level: .debug)
} }
} }
@@ -627,8 +664,7 @@ final class BLEService: NSObject {
return collectionsQueue.sync { return collectionsQueue.sync {
if let publicKey = peers[peerID]?.noisePublicKey { if let publicKey = peers[peerID]?.noisePublicKey {
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key) // Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
let hash = SHA256.hash(data: publicKey) return publicKey.sha256Fingerprint()
return hash.map { String(format: "%02x", $0) }.joined()
} }
return nil return nil
} }
@@ -697,19 +733,26 @@ final class BLEService: NSObject {
self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID) self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID)
} else { } else {
// Public broadcast // Public broadcast
// Public message - logged at relay point for mesh debugging // Create packet with explicit fields so we can sign it
let packet = BitchatPacket( let basePacket = BitchatPacket(
type: MessageType.message.rawValue, type: MessageType.message.rawValue,
ttl: self.messageTTL, senderID: Data(hexString: self.myPeerID) ?? Data(),
senderID: self.myPeerID, recipientID: nil,
payload: Data(content.utf8) timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(content.utf8),
signature: nil,
ttl: self.messageTTL
) )
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
SecureLogger.log("❌ Failed to sign public message", category: SecureLogger.security, level: .error)
return
}
// Pre-mark our own broadcast as processed to avoid handling relayed self copy // Pre-mark our own broadcast as processed to avoid handling relayed self copy
let senderHex = packet.senderID.hexEncodedString() let senderHex = signedPacket.senderID.hexEncodedString()
let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)" let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)"
self.messageDeduplicator.markProcessed(dedupID) self.messageDeduplicator.markProcessed(dedupID)
// Call synchronously since we're already on background queue // Call synchronously since we're already on background queue
self.broadcastPacket(packet) self.broadcastPacket(signedPacket)
} }
} }
} }
@@ -1037,15 +1080,23 @@ final class BLEService: NSObject {
} }
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically // For broadcast (no directed peer) and non-fragment, choose a subset deterministically
// Special-case announces: do NOT subset to maximize reach for presence
var selectedPeripheralIDs = Set(allowedPeripheralIDs) var selectedPeripheralIDs = Set(allowedPeripheralIDs)
var selectedCentralIDs = Set(allowedCentralIDs) var selectedCentralIDs = Set(allowedCentralIDs)
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue { if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.rawValue {
let kp = subsetSizeForFanout(allowedPeripheralIDs.count) let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
let kc = subsetSizeForFanout(allowedCentralIDs.count) let kc = subsetSizeForFanout(allowedCentralIDs.count)
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID) selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID) selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID)
} }
// If directed and we currently have no links to forward on, spool for a short window
if let only = directedOnlyPeer,
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
spoolDirectedPacket(packet, recipientPeerID: only)
}
// Writes to selected connected peripherals // Writes to selected connected peripherals
for s in states where s.isConnected { for s in states where s.isConnected {
let pid = s.peripheral.identifier.uuidString let pid = s.peripheral.identifier.uuidString
@@ -1062,6 +1113,57 @@ final class BLEService: NSObject {
} }
} }
} }
// MARK: - Directed store-and-forward
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) {
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:]
if byMsg[msgID] == nil {
byMsg[msgID] = (packet: packet, enqueuedAt: Date())
self.pendingDirectedRelays[recipientPeerID] = byMsg
SecureLogger.log("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))", category: SecureLogger.session, level: .debug)
}
}
}
private func flushDirectedSpool() {
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
let toSend: [(String, BitchatPacket)] = collectionsQueue.sync(flags: .barrier) {
var out: [(String, BitchatPacket)] = []
let now = Date()
for (recipient, dict) in pendingDirectedRelays {
for (_, entry) in dict {
if now.timeIntervalSince(entry.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds {
out.append((recipient, entry.packet))
}
}
// Clear recipient bucket; items will be re-spooled if still no links
pendingDirectedRelays.removeValue(forKey: recipient)
}
return out
}
guard !toSend.isEmpty else { return }
for (_, packet) in toSend {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
}
private func rebroadcastRecentAnnounces() {
// Snapshot sender order to preserve ordering and avoid holding locks while sending
let packets: [BitchatPacket] = collectionsQueue.sync {
recentAnnounceOrder.compactMap { recentAnnounceBySender[$0] }
}
guard !packets.isEmpty else { return }
for (idx, pkt) in packets.enumerated() {
// Stagger slightly to avoid bursts
let delayMs = idx * 20
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
self?.broadcastPacket(pkt)
}
}
}
private func sendData(_ data: Data, to peripheral: CBPeripheral) { private func sendData(_ data: Data, to peripheral: CBPeripheral) {
// Fire-and-forget: Simple send without complex fallback logic // Fire-and-forget: Simple send without complex fallback logic
@@ -1127,7 +1229,8 @@ final class BLEService: NSObject {
ttl: packet.ttl ttl: packet.ttl
) )
// Pace fragments with small jitter to avoid bursts // Pace fragments with small jitter to avoid bursts
let delayMs = index * TransportConfig.bleFragmentSpacingMs // ~6ms spacing per fragment let perFragMs = (directedOnlyPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs
let delayMs = index * perFragMs
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
self?.broadcastPacket(fragmentPacket) self?.broadcastPacket(fragmentPacket)
} }
@@ -1222,10 +1325,14 @@ final class BLEService: NSObject {
SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)", SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
} }
// Cancel any pending relay for this message (arrived via another neighbor) // In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging.
collectionsQueue.async(flags: .barrier) { [weak self] in // In denser graphs, cancel the pending relay to reduce redundant floods.
if let task = self?.scheduledRelays.removeValue(forKey: messageID) { let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
task.cancel() if connectedCount > 2 {
collectionsQueue.async(flags: .barrier) { [weak self] in
if let task = self?.scheduledRelays.removeValue(forKey: messageID) {
task.cancel()
}
} }
} }
return // Duplicate ignored return // Duplicate ignored
@@ -1284,6 +1391,7 @@ final class BLEService: NSObject {
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil), isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil),
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue, isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
isAnnounce: packet.type == MessageType.announce.rawValue,
degree: degree, degree: degree,
highDegreeThreshold: highDegreeThreshold highDegreeThreshold: highDegreeThreshold
) )
@@ -1334,11 +1442,14 @@ final class BLEService: NSObject {
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
// Check if we have an actual BLE connection to this peer // Check if we have an actual BLE connection to this peer
let peripheralUUID = peerToPeripheralUUID[peerID] let peripheralUUID = peerToPeripheralUUID[peerID]
_ = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true // hasPeripheralConnection let hasPeripheralConnection = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true
// Check if this peer is subscribed to us as a central // Check if this peer is subscribed to us as a central
// Note: We can't identify which specific central is which peer without additional mapping // Note: We can't identify which specific central is which peer without additional mapping
_ = !subscribedCentrals.isEmpty // hasCentralSubscription let hasCentralSubscription = centralToPeerID.values.contains(peerID)
// Direct announces arrive with full TTL (no prior hop)
let isDirectAnnounce = (packet.ttl == messageTTL)
// Check if we already have this peer (might be reconnecting) // Check if we already have this peer (might be reconnecting)
let existingPeer = peers[peerID] let existingPeer = peers[peerID]
@@ -1376,7 +1487,7 @@ final class BLEService: NSObject {
peers[peerID] = PeerInfo( peers[peerID] = PeerInfo(
id: existing.id, id: existing.id,
nickname: announcement.nickname, nickname: announcement.nickname,
isConnected: true, isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
noisePublicKey: announcement.noisePublicKey, noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey, signingPublicKey: announcement.signingPublicKey,
isVerifiedNickname: true, isVerifiedNickname: true,
@@ -1387,7 +1498,7 @@ final class BLEService: NSObject {
peers[peerID] = PeerInfo( peers[peerID] = PeerInfo(
id: peerID, id: peerID,
nickname: announcement.nickname, nickname: announcement.nickname,
isConnected: true, isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
noisePublicKey: announcement.noisePublicKey, noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey, signingPublicKey: announcement.signingPublicKey,
isVerifiedNickname: true, isVerifiedNickname: true,
@@ -1395,16 +1506,52 @@ final class BLEService: NSObject {
) )
} }
// Log connection status // Log connection status only for direct connectivity changes; debounce to reduce spam
if existingPeer == nil { if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug) let now = Date()
} else if wasDisconnected { if existingPeer == nil {
SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug) SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug)
} else if existingPeer?.nickname != announcement.nickname { } else if wasDisconnected {
SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug) // Debounce 'reconnected' logs within short window
if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds {
// Skip duplicate log
} else {
SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug)
lastReconnectLogAt[peerID] = now
}
} else if existingPeer?.nickname != announcement.nickname {
SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug)
}
} }
} }
// Persist cryptographic identity and signing key for robust offline verification
do {
// Derive fingerprint from Noise public key
let hash = SHA256.hash(data: announcement.noisePublicKey)
let fingerprint = hash.map { String(format: "%02x", $0) }.joined()
SecureIdentityStateManager.shared.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
claimedNickname: announcement.nickname
)
}
// Record this announce for lightweight rebroadcast buffer (exclude self)
if peerID != myPeerID {
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self.recentAnnounceBySender[peerID] = packet
if !self.recentAnnounceOrder.contains(peerID) { self.recentAnnounceOrder.append(peerID) }
// Trim to cap, oldest first
while self.recentAnnounceOrder.count > self.recentAnnounceBufferCap {
let victim = self.recentAnnounceOrder.removeFirst()
self.recentAnnounceBySender.removeValue(forKey: victim)
}
}
}
// Notify UI on main thread // Notify UI on main thread
notifyUI { [weak self] in notifyUI { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -1412,8 +1559,8 @@ final class BLEService: NSObject {
// Get current peer list (after addition) // Get current peer list (after addition)
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
// Only notify of connection for new or reconnected peers // Only notify of connection for new or reconnected peers when it is a direct announce
if isNewPeer || isReconnectedPeer { if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
self.delegate?.didConnectToPeer(peerID) self.delegate?.didConnectToPeer(peerID)
} }
@@ -1433,6 +1580,14 @@ final class BLEService: NSObject {
// Force send to ensure the peer receives our announce // Force send to ensure the peer receives our announce
sendAnnounce(forceSend: true) sendAnnounce(forceSend: true)
} }
// Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop
if isNewPeer {
let delay = Double.random(in: 0.3...0.6)
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.sendAnnounce(forceSend: true)
}
}
} }
// Mention parsing moved to ChatViewModel // Mention parsing moved to ChatViewModel
@@ -1440,9 +1595,41 @@ final class BLEService: NSObject {
private func handleMessage(_ packet: BitchatPacket, from peerID: String) { private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
// Ignore self-origin public messages that may be seen again via relay // Ignore self-origin public messages that may be seen again via relay
if peerID == myPeerID { return } if peerID == myPeerID { return }
// Enforce: only accept public messages from verified peers we know var accepted = false
guard let info = peers[peerID], info.isVerifiedNickname else { var senderNickname: String = ""
if let info = peers[peerID], info.isVerifiedNickname {
// Known verified peer path
accepted = true
senderNickname = info.nickname
// Handle nickname collisions
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname)
if hasCollision {
senderNickname += "#" + String(peerID.prefix(4))
}
} else {
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
// Find candidate identities by peerID prefix (16 hex)
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(peerID)
for candidate in candidates {
if let signingKey = candidate.signingPublicKey,
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
accepted = true
// Prefer persisted social petname or claimed nickname
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: candidate.fingerprint) {
senderNickname = social.localPetname ?? social.claimedNickname
} else {
senderNickname = "anon" + String(peerID.prefix(4))
}
break
}
}
}
}
guard accepted else {
SecureLogger.log("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))", category: SecureLogger.security, level: .warning) SecureLogger.log("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))", category: SecureLogger.security, level: .warning)
return return
} }
@@ -1451,17 +1638,17 @@ final class BLEService: NSObject {
SecureLogger.log("❌ Failed to decode message payload as UTF-8", category: SecureLogger.session, level: .error) SecureLogger.log("❌ Failed to decode message payload as UTF-8", category: SecureLogger.session, level: .error)
return return
} }
// Determine if we have a direct link to the sender
// Resolve display nickname; if collisions exist, append short peerID suffix let hasDirectLink: Bool = collectionsQueue.sync {
var senderNickname = info.nickname let perUUID = peerToPeripheralUUID[peerID]
// Treat a collision if another connected peer shares the nickname OR our own nickname matches let perConnected = perUUID != nil && peripherals[perUUID!]?.isConnected == true
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname) let hasCentral = centralToPeerID.values.contains(peerID)
if hasCollision { return perConnected || hasCentral
senderNickname += "#" + String(peerID.prefix(4))
} }
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug) let pathTag = hasDirectLink ? "direct" : "mesh"
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in notifyUI { [weak self] in
self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts) self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
@@ -1659,31 +1846,64 @@ final class BLEService: NSObject {
} }
func sendDeliveryAck(for messageID: String, to peerID: String) { func sendDeliveryAck(for messageID: String, to peerID: String) {
// Send encrypted delivery ACK // Create typed payload: [type byte] + [message ID]
guard noiseService.hasSession(with: peerID) else { var payload = Data([NoisePayloadType.delivered.rawValue])
SecureLogger.log("Cannot send ACK - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning) payload.append(contentsOf: messageID.utf8)
return
if noiseService.hasEstablishedSession(with: peerID) {
do {
let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted,
signature: nil,
ttl: messageTTL
)
broadcastPacket(packet)
} catch {
SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error)
}
} else {
// Queue for after handshake and initiate if needed
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
}
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
SecureLogger.log("🕒 Queued DELIVERED ack for \(peerID) until handshake completes",
category: SecureLogger.session, level: .debug)
} }
}
// Create ACK payload: [type byte] + [message ID]
var ackPayload = Data([NoisePayloadType.delivered.rawValue]) private func sendPendingNoisePayloadsAfterHandshake(for peerID: String) {
ackPayload.append(contentsOf: messageID.utf8) let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
let list = pendingNoisePayloadsAfterHandshake[peerID] ?? []
do { pendingNoisePayloadsAfterHandshake.removeValue(forKey: peerID)
let encrypted = try noiseService.encrypt(ackPayload, for: peerID) return list
let packet = BitchatPacket( }
type: MessageType.noiseEncrypted.rawValue, guard !payloads.isEmpty else { return }
senderID: Data(hexString: myPeerID) ?? Data(), SecureLogger.log("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake",
recipientID: Data(hexString: peerID), category: SecureLogger.session, level: .debug)
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), for payload in payloads {
payload: encrypted, do {
signature: nil, let encrypted = try noiseService.encrypt(payload, for: peerID)
ttl: messageTTL let packet = BitchatPacket(
) type: MessageType.noiseEncrypted.rawValue,
broadcastPacket(packet) senderID: Data(hexString: myPeerID) ?? Data(),
// Delivery ACK sent recipientID: Data(hexString: peerID),
} catch { timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error) payload: encrypted,
signature: nil,
ttl: messageTTL
)
broadcastPacket(packet)
} catch {
SecureLogger.log("❌ Failed to send pending noise payload to \(peerID): \(error)",
category: SecureLogger.noise, level: .error)
}
} }
} }
@@ -1696,6 +1916,18 @@ final class BLEService: NSObject {
} }
} }
} }
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
private func notifyPeerDisconnectedDebounced(_ peerID: String) {
let now = Date()
let last = recentDisconnectNotifies[peerID]
if last == nil || now.timeIntervalSince(last!) >= TransportConfig.bleDisconnectNotifyDebounceSeconds {
delegate?.didDisconnectFromPeer(peerID)
recentDisconnectNotifies[peerID] = now
} else {
// Suppressed duplicate disconnect notification
}
}
// NEW: Publish peer snapshots to subscribers and notify Transport delegates // NEW: Publish peer snapshots to subscribers and notify Transport delegates
private func publishFullPeerData() { private func publishFullPeerData() {
@@ -1748,6 +1980,16 @@ final class BLEService: NSObject {
let target = base + Double.random(in: -jitter...jitter) let target = base + Double.random(in: -jitter...jitter)
if elapsed >= target { sendAnnounce(forceSend: true) } if elapsed >= target { sendAnnounce(forceSend: true) }
} }
// Activity-driven quick-announce: if we've seen any packet in last 5s and it has
// been >=10s since the last announce, send a presence nudge.
let recentSeen = collectionsQueue.sync { () -> Bool in
let cutoff = now.addingTimeInterval(-5.0)
return recentPacketTimestamps.contains(where: { $0 >= cutoff })
}
if recentSeen && elapsed >= 10.0 {
sendAnnounce(forceSend: true)
}
// If we have no peers, ensure we're scanning and advertising // If we have no peers, ensure we're scanning and advertising
if peers.isEmpty { if peers.isEmpty {
@@ -1761,15 +2003,18 @@ final class BLEService: NSObject {
updateScanningDutyCycle(connectedCount: connectedCount) updateScanningDutyCycle(connectedCount: connectedCount)
updateRSSIThreshold(connectedCount: connectedCount) updateRSSIThreshold(connectedCount: connectedCount)
// Every 20 seconds (2 cycles): Check peer connectivity // Check peer connectivity every cycle for snappier UI updates
if maintenanceCounter % 2 == 0 { checkPeerConnectivity()
checkPeerConnectivity()
}
// Every 30 seconds (3 cycles): Cleanup // Every 30 seconds (3 cycles): Cleanup
if maintenanceCounter % 3 == 0 { if maintenanceCounter % 3 == 0 {
performCleanup() performCleanup()
} }
// Attempt to flush any spooled directed messages periodically (~every 5 seconds)
if maintenanceCounter % 2 == 1 {
flushDirectedSpool()
}
// No rotating alias: nothing to refresh // No rotating alias: nothing to refresh
@@ -1783,28 +2028,39 @@ final class BLEService: NSObject {
let now = Date() let now = Date()
var disconnectedPeers: [String] = [] var disconnectedPeers: [String] = []
var removedOfflineCount = 0
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
for (peerID, peer) in peers { for (peerID, peer) in peers {
if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > TransportConfig.blePeerInactivityTimeoutSeconds { let age = now.timeIntervalSince(peer.lastSeen)
let retention: TimeInterval = peer.isVerifiedNickname ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
// Check if we still have an active BLE connection to this peer // Check if we still have an active BLE connection to this peer
let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil && let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil &&
peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true
let hasCentralConnection = centralToPeerID.values.contains(peerID) let hasCentralConnection = centralToPeerID.values.contains(peerID)
// Only remove if we don't have an active BLE connection // If direct link is gone, mark as not connected (retain entry for reachability)
if !hasPeripheralConnection && !hasCentralConnection { if !hasPeripheralConnection && !hasCentralConnection {
// Remove the peer completely (they'll be re-added when they reconnect) var updated = peer
SecureLogger.log("⏱️ Peer timed out (no packets for 20s): \(peerID) (\(peer.nickname))", updated.isConnected = false
category: SecureLogger.session, level: .debug) peers[peerID] = updated
peers.removeValue(forKey: peerID)
disconnectedPeers.append(peerID) disconnectedPeers.append(peerID)
} }
} }
// Cleanup: remove peers that are not connected and past reachability retention
if !peer.isConnected {
if age > retention {
SecureLogger.log("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))",
category: SecureLogger.session, level: .debug)
peers.removeValue(forKey: peerID)
removedOfflineCount += 1
}
}
} }
} }
// Update UI if any peers were disconnected // Update UI if there were direct disconnections or offline removals
if !disconnectedPeers.isEmpty { if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {
notifyUI { [weak self] in notifyUI { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -1814,6 +2070,8 @@ final class BLEService: NSObject {
for peerID in disconnectedPeers { for peerID in disconnectedPeers {
self.delegate?.didDisconnectFromPeer(peerID) self.delegate?.didDisconnectFromPeer(peerID)
} }
// Publish snapshots so UnifiedPeerService updates connection/reachability icons
self.publishFullPeerData()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
@@ -1857,6 +2115,15 @@ final class BLEService: NSObject {
if !self.ingressByMessageID.isEmpty { if !self.ingressByMessageID.isEmpty {
self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff } self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff }
} }
// Clean expired directed spooled items
if !self.pendingDirectedRelays.isEmpty {
var cleaned: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
for (recipient, dict) in self.pendingDirectedRelays {
let pruned = dict.filter { now.timeIntervalSince($0.value.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds }
if !pruned.isEmpty { cleaned[recipient] = pruned }
}
self.pendingDirectedRelays = cleaned
}
} }
} }
@@ -1868,7 +2135,13 @@ final class BLEService: NSObject {
#else #else
let active = true let active = true
#endif #endif
let shouldDuty = dutyEnabled && active && connectedCount > 0 // Force full-time scanning if we have very few neighbors or very recent traffic
let hasRecentTraffic: Bool = collectionsQueue.sync {
let cutoff = Date().addingTimeInterval(-TransportConfig.bleRecentTrafficForceScanSeconds)
return recentPacketTimestamps.contains(where: { $0 >= cutoff })
}
let forceScanOn = (connectedCount <= 2) || hasRecentTraffic
let shouldDuty = dutyEnabled && active && connectedCount > 0 && !forceScanOn
if shouldDuty { if shouldDuty {
if scanDutyTimer == nil { if scanDutyTimer == nil {
// Start timer to toggle scanning on/off // Start timer to toggle scanning on/off
@@ -2137,6 +2410,11 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash
if error != nil {
recentConnectTimeouts[peripheralID] = Date()
}
// Clean up references // Clean up references
peripherals.removeValue(forKey: peripheralID) peripherals.removeValue(forKey: peripheralID)
@@ -2145,9 +2423,12 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
if let peerID = peerID { if let peerID = peerID {
peerToPeripheralUUID.removeValue(forKey: peerID) peerToPeripheralUUID.removeValue(forKey: peerID)
// Remove peer completely (they'll be re-added when they reconnect and announce) // Do not remove peer; mark as not connected but retain for reachability
_ = collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
peers.removeValue(forKey: peerID) if var info = peers[peerID] {
info.isConnected = false
peers[peerID] = info
}
} }
} }
@@ -2162,7 +2443,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
// Attempt to fill freed slot from queue // Attempt to fill freed slot from queue
bleQueue.async { [weak self] in self?.tryConnectFromQueue() } bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
// Notify delegate about disconnection on main thread // Notify delegate about disconnection on main thread (direct link dropped)
notifyUI { [weak self] in notifyUI { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -2170,7 +2451,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
if let peerID = peerID { if let peerID = peerID {
self.delegate?.didDisconnectFromPeer(peerID) self.notifyPeerDisconnectedDebounced(peerID)
} }
self.publishFullPeerData() self.publishFullPeerData()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
@@ -2222,6 +2503,18 @@ extension BLEService {
guard candidate.isConnectable else { return } guard candidate.isConnectable else { return }
let peripheral = candidate.peripheral let peripheral = candidate.peripheral
let peripheralID = peripheral.identifier.uuidString let peripheralID = peripheral.identifier.uuidString
// Weak-link cooldown: if we recently timed out and RSSI is very weak, delay retries
if let lastTO = recentConnectTimeouts[peripheralID] {
let elapsed = Date().timeIntervalSince(lastTO)
if elapsed < TransportConfig.bleWeakLinkCooldownSeconds && candidate.rssi <= TransportConfig.bleWeakLinkRSSICutoff {
// Requeue the candidate and try again later
connectionCandidates.append(candidate)
let remaining = TransportConfig.bleWeakLinkCooldownSeconds - elapsed
let delay = min(max(2.0, remaining), 15.0)
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
return
}
}
if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true { if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true {
// Already in progress; skip // Already in progress; skip
bleQueue.async { [weak self] in self?.tryConnectFromQueue() } bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
@@ -2355,6 +2648,10 @@ extension BLEService: CBPeripheralDelegate {
// Send announce after subscription is confirmed (force send for new connection) // Send announce after subscription is confirmed (force send for new connection)
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true) self?.sendAnnounce(forceSend: true)
// Try flushing any spooled directed packets now that we have a link
self?.flushDirectedSpool()
// Rebroadcast a couple of recent announces to seed the new link
self?.rebroadcastRecentAnnounces()
} }
} else { } else {
SecureLogger.log("⚠️ Characteristic does not support notifications", category: SecureLogger.session, level: .warning) SecureLogger.log("⚠️ Characteristic does not support notifications", category: SecureLogger.session, level: .warning)
@@ -2517,6 +2814,10 @@ extension BLEService: CBPeripheralManagerDelegate {
// Send announce to the newly subscribed central after a small delay to avoid overwhelming // Send announce to the newly subscribed central after a small delay to avoid overwhelming
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true) self?.sendAnnounce(forceSend: true)
// Flush any spooled directed packets now that we have a central subscribed
self?.flushDirectedSpool()
// Rebroadcast a couple of recent announces to seed the new link
self?.rebroadcastRecentAnnounces()
} }
} }
@@ -2533,9 +2834,12 @@ extension BLEService: CBPeripheralManagerDelegate {
// Find and disconnect the peer associated with this central // Find and disconnect the peer associated with this central
let centralUUID = central.identifier.uuidString let centralUUID = central.identifier.uuidString
if let peerID = centralToPeerID[centralUUID] { if let peerID = centralToPeerID[centralUUID] {
// Remove peer completely (they'll be re-added when they reconnect) // Mark peer as not connected; retain for reachability
_ = collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
peers.removeValue(forKey: peerID) if var info = peers[peerID] {
info.isConnected = false
peers[peerID] = info
}
} }
// Clean up mappings // Clean up mappings
@@ -2548,7 +2852,9 @@ extension BLEService: CBPeripheralManagerDelegate {
// Get current peer list (after removal) // Get current peer list (after removal)
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
self.delegate?.didDisconnectFromPeer(peerID) self.notifyPeerDisconnectedDebounced(peerID)
// Publish snapshots so UnifiedPeerService can refresh icons promptly
self.publishFullPeerData()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
@@ -181,7 +181,7 @@ class FavoritesPersistenceService: ObservableObject {
/// Falls back to scanning favorites and matching on derived peer ID. /// Falls back to scanning favorites and matching on derived peer ID.
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? { func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
// Quick sanity: peerID should be 16 hex chars (8 bytes) // Quick sanity: peerID should be 16 hex chars (8 bytes)
guard peerID.count == 16 else { return nil } guard PeerIDResolver.isShortID(peerID) else { return nil }
for (pubkey, rel) in favorites { for (pubkey, rel) in favorites {
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey) let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
if derived == peerID { return rel } if derived == peerID { return rel }
+14 -7
View File
@@ -92,23 +92,30 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} }
} }
/// Begin periodic one-shot location refreshes while a selector UI is visible. /// Begin continuous, distance-filtered updates while the channel sheet is visible.
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) { func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
guard permissionState == .authorized else { return } guard permissionState == .authorized else { return }
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open // Stop any previous polling timer
refreshTimer?.invalidate() refreshTimer?.invalidate()
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in refreshTimer = nil
self?.requestOneShotLocation() // Tighten accuracy and distance filter for live view
} cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
// Kick off immediately cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
// Start continuous updates
cl.startUpdatingLocation()
// Request an immediate fix to populate UI without waiting for movement
requestOneShotLocation() requestOneShotLocation()
} }
/// Stop periodic refreshes when selector UI is dismissed. /// Stop continuous refreshes when selector UI is dismissed.
func endLiveRefresh() { func endLiveRefresh() {
refreshTimer?.invalidate() refreshTimer?.invalidate()
refreshTimer = nil refreshTimer = nil
cl.stopUpdatingLocation() cl.stopUpdatingLocation()
// Restore more relaxed defaults for background/idle state
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
} }
func select(_ channel: ChannelID) { func select(_ channel: ChannelID) {
+14 -13
View File
@@ -4,13 +4,12 @@ import Foundation
@MainActor @MainActor
final class MessageRouter { final class MessageRouter {
private let mesh: Transport private let mesh: Transport
private let nostr: NostrTransport private let nostr: NostrOutboxService
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
init(mesh: Transport, nostr: NostrTransport) { init(mesh: Transport, nostr: NostrOutboxService) {
self.mesh = mesh self.mesh = mesh
self.nostr = nostr self.nostr = nostr
self.nostr.senderPeerID = mesh.myPeerID
// Observe favorites changes to learn Nostr mapping and flush queued messages // Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -37,16 +36,16 @@ final class MessageRouter {
} }
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
let hasMesh = mesh.isPeerConnected(peerID) let reachableMesh = mesh.isPeerReachable(peerID)
let hasEstablished = mesh.getNoiseService().hasEstablishedSession(with: peerID) if reachableMesh {
if hasMesh && hasEstablished { SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
SecureLogger.log("Routing PM via mesh to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
// BLEService will initiate a handshake if needed and queue the message
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) nostr.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else { } else {
// Queue for later (when mesh connects or Nostr mapping appears) // Queue for later (when mesh connects or Nostr mapping appears)
if outbox[peerID] == nil { outbox[peerID] = [] } if outbox[peerID] == nil { outbox[peerID] = [] }
@@ -57,9 +56,9 @@ final class MessageRouter {
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
// Prefer mesh only if a Noise session is established; else use Nostr to avoid handshakeRequired spam // Prefer mesh for reachable peers; BLE will queue if handshake is needed
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.log("Routing READ ack via mesh to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
mesh.sendReadReceipt(receipt, to: peerID) mesh.sendReadReceipt(receipt, to: peerID)
} else { } else {
@@ -70,7 +69,9 @@ final class MessageRouter {
} }
func sendDeliveryAck(_ messageID: String, to peerID: String) { func sendDeliveryAck(_ messageID: String, to peerID: String) {
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
mesh.sendDeliveryAck(for: messageID, to: peerID) mesh.sendDeliveryAck(for: messageID, to: peerID)
} else { } else {
nostr.sendDeliveryAck(for: messageID, to: peerID) nostr.sendDeliveryAck(for: messageID, to: peerID)
@@ -101,7 +102,7 @@ final class MessageRouter {
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
// Prefer mesh if connected; else try Nostr if mapping exists // Prefer mesh if connected; else try Nostr if mapping exists
for (content, nickname, messageID) in queued { for (content, nickname, messageID) in queued {
if mesh.isPeerConnected(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
@@ -548,8 +548,7 @@ class NoiseEncryptionService {
} }
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String { private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
let hash = SHA256.hash(data: publicKey.rawRepresentation) return publicKey.rawRepresentation.sha256Fingerprint()
return hash.map { String(format: "%02x", $0) }.joined()
} }
// MARK: - Session Maintenance // MARK: - Session Maintenance
+90
View File
@@ -0,0 +1,90 @@
//
// NostrInboxService.swift
// bitchat
//
// Centralizes Nostr subscribe/unsubscribe and event de-duplication for inbox flows.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@MainActor
final class NostrInboxService {
private var processedIDs: Set<String> = []
private var order: [String] = []
private let cap: Int
init(capacity: Int = TransportConfig.uiProcessedNostrEventsCap) {
self.cap = capacity
}
private func track(_ id: String) -> Bool {
if processedIDs.contains(id) { return false }
processedIDs.insert(id)
order.append(id)
if order.count > cap {
let overflow = order.count - cap
for _ in 0..<overflow {
if let oldest = order.first {
order.removeFirst()
processedIDs.remove(oldest)
}
}
}
return true
}
func reset() {
processedIDs.removeAll()
order.removeAll()
}
// Subscribe to public geohash ephemeral events (kind 20000)
@discardableResult
func subscribeGeohashEphemeral(
geohash: String,
lookbackSeconds: TimeInterval,
limit: Int,
relayCount: Int,
subID: String? = nil,
handler: @escaping (NostrEvent) -> Void
) -> String {
let id = subID ?? "geo-\(geohash)"
let filter = NostrFilter.geohashEphemeral(
geohash,
since: Date().addingTimeInterval(-lookbackSeconds),
limit: limit
)
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: relayCount)
NostrRelayManager.shared.subscribe(filter: filter, id: id, relayUrls: relays) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
guard self.track(event.id) else { return }
handler(event)
}
return id
}
// Subscribe to gift wraps (DMs) for a given pubkey
@discardableResult
func subscribeGiftWrapsFor(
pubkeyHex: String,
lookbackSeconds: TimeInterval,
subID: String? = nil,
handler: @escaping (NostrEvent) -> Void
) -> String {
let id = subID ?? "dm-\(pubkeyHex.prefix(8))"
let filter = NostrFilter.giftWrapsFor(pubkey: pubkeyHex, since: Date().addingTimeInterval(-lookbackSeconds))
NostrRelayManager.shared.subscribe(filter: filter, id: id) { [weak self] event in
guard let self = self else { return }
guard self.track(event.id) else { return }
handler(event)
}
return id
}
func unsubscribe(id: String) {
NostrRelayManager.shared.unsubscribe(id: id)
}
}
+50
View File
@@ -0,0 +1,50 @@
//
// NostrOutboxService.swift
// bitchat
//
// Centralized Nostr outbox for sending PMs and ACKs.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@MainActor
final class NostrOutboxService {
private let transport: NostrTransport
init(meshService: Transport) {
let t = NostrTransport()
t.senderPeerID = meshService.myPeerID
self.transport = t
}
func sendGeohashPM(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
transport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: identity, messageID: messageID)
}
func sendGeohashDeliveredAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
transport.sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
}
func sendGeohashReadAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
transport.sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
}
// MARK: - Generic outbox via favorites mapping
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
transport.sendReadReceipt(receipt, to: peerID)
}
func sendDeliveryAck(for messageID: String, to peerID: String) {
transport.sendDeliveryAck(for: messageID, to: peerID)
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
}
+2 -1
View File
@@ -31,6 +31,7 @@ final class NostrTransport: Transport {
func emergencyDisconnectAll() { /* no-op */ } func emergencyDisconnectAll() { /* no-op */ }
func isPeerConnected(_ peerID: String) -> Bool { false } func isPeerConnected(_ peerID: String) -> Bool { false }
func isPeerReachable(_ peerID: String) -> Bool { false }
func peerNickname(peerID: String) -> String? { nil } func peerNickname(peerID: String) -> String? { nil }
func getPeerNicknames() -> [String : String] { [:] } func getPeerNicknames() -> [String : String] { [:] }
@@ -166,7 +167,7 @@ final class NostrTransport: Transport {
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
} }
if peerID.count == 16, if PeerIDResolver.isShortID(peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
@@ -90,6 +90,15 @@ class NotificationService {
} }
} }
// Geohash public chat notification with deep link to a specific geohash
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
let title = "\(titlePrefix)\(geohash)"
let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)"
let deeplink = "bitchat://geohash/\(geohash)"
let userInfo: [String: Any] = ["deeplink": deeplink]
sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo)
}
func sendNetworkAvailableNotification(peerCount: Int) { func sendNetworkAvailableNotification(peerCount: Int) {
let title = "👥 bitchatters nearby!" let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around" let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
+7 -6
View File
@@ -16,14 +16,15 @@ class PrivateChatManager: ObservableObject {
@Published var unreadMessages: Set<String> = [] @Published var unreadMessages: Set<String> = []
private var selectedPeerFingerprint: String? = nil private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel private var readReceiptTracker: ReadReceiptTracker?
weak var meshService: Transport? weak var meshService: Transport?
// Route acks/receipts via MessageRouter (chooses mesh or Nostr) // Route acks/receipts via MessageRouter (chooses mesh or Nostr)
weak var messageRouter: MessageRouter? weak var messageRouter: MessageRouter?
init(meshService: Transport? = nil) { init(meshService: Transport? = nil, readReceiptTracker: ReadReceiptTracker? = nil) {
self.meshService = meshService self.meshService = meshService
self.readReceiptTracker = readReceiptTracker
} }
// Cap for messages stored per private chat // Cap for messages stored per private chat
@@ -121,7 +122,7 @@ class PrivateChatManager: ObservableObject {
unreadMessages.insert(senderPeerID) unreadMessages.insert(senderPeerID)
// Avoid notifying for messages already marked as read (dup/resubscribe cases) // Avoid notifying for messages already marked as read (dup/resubscribe cases)
if !sentReadReceipts.contains(message.id) { if !(readReceiptTracker?.contains(message.id) ?? false) {
NotificationService.shared.sendPrivateMessageNotification( NotificationService.shared.sendPrivateMessageNotification(
from: message.sender, from: message.sender,
message: message.content, message: message.content,
@@ -160,7 +161,7 @@ class PrivateChatManager: ObservableObject {
// Send read receipts for unread messages that haven't been sent yet // Send read receipts for unread messages that haven't been sent yet
if let messages = privateChats[peerID] { if let messages = privateChats[peerID] {
for message in messages { for message in messages {
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) { if message.senderPeerID == peerID && !message.isRelay && !(readReceiptTracker?.contains(message.id) ?? false) {
sendReadReceipt(for: message) sendReadReceipt(for: message)
} }
} }
@@ -212,12 +213,12 @@ class PrivateChatManager: ObservableObject {
// MARK: - Private Methods // MARK: - Private Methods
private func sendReadReceipt(for message: BitchatMessage) { private func sendReadReceipt(for message: BitchatMessage) {
guard !sentReadReceipts.contains(message.id), guard !(readReceiptTracker?.contains(message.id) ?? false),
let senderPeerID = message.senderPeerID else { let senderPeerID = message.senderPeerID else {
return return
} }
sentReadReceipts.insert(message.id) readReceiptTracker?.insert(message.id)
// Create read receipt using the simplified method // Create read receipt using the simplified method
let receipt = ReadReceipt( let receipt = ReadReceipt(
+77
View File
@@ -0,0 +1,77 @@
//
// ReadReceiptTracker.swift
// bitchat
//
// Centralized tracker for sent read receipts with simple persistence.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
final class ReadReceiptTracker {
private let defaults: UserDefaults
private let key = "sentReadReceipts"
private let queue = DispatchQueue(label: "chat.bitchat.readreceipts", attributes: .concurrent)
private var set: Set<String> = []
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: key),
let arr = try? JSONDecoder().decode([String].self, from: data) {
self.set = Set(arr)
}
}
func contains(_ id: String) -> Bool {
queue.sync { set.contains(id) }
}
func insert(_ id: String) {
queue.async(flags: .barrier) {
if self.set.insert(id).inserted { self.persist() }
}
}
func insert<S: Sequence>(_ ids: S) where S.Element == String {
queue.async(flags: .barrier) {
var changed = false
for id in ids { changed = self.set.insert(id).inserted || changed }
if changed { self.persist() }
}
}
func remove(_ id: String) {
queue.async(flags: .barrier) {
if self.set.remove(id) != nil { self.persist() }
}
}
func removeAll() {
queue.async(flags: .barrier) {
if !self.set.isEmpty { self.set.removeAll(); self.persist() }
}
}
/// Keep only IDs present in the allow-list; useful for pruning stale entries.
func prune(toAllowedIDs allowed: Set<String>) {
queue.async(flags: .barrier) {
let newSet = self.set.intersection(allowed)
if newSet.count != self.set.count { self.set = newSet; self.persist() }
}
}
/// Snapshot current set for read-only operations (avoid long-lived copies in hot paths)
func snapshot() -> Set<String> { queue.sync { set } }
private func persist() {
let arr = Array(set)
if let data = try? JSONEncoder().encode(arr) {
defaults.set(data, forKey: key)
} else {
SecureLogger.log("❌ Failed to encode read receipts for persistence",
category: SecureLogger.session, level: .error)
}
}
}
+13 -4
View File
@@ -15,6 +15,7 @@ struct RelayController {
isDirectedEncrypted: Bool, isDirectedEncrypted: Bool,
isDirectedFragment: Bool, isDirectedFragment: Bool,
isHandshake: Bool, isHandshake: Bool,
isAnnounce: Bool,
degree: Int, degree: Int,
highDegreeThreshold: Int) -> RelayDecision { highDegreeThreshold: Int) -> RelayDecision {
// Suppress obvious non-relays // Suppress obvious non-relays
@@ -25,7 +26,8 @@ struct RelayController {
// Always relay with no TTL cap for these types // Always relay with no TTL cap for these types
let newTTL = (ttl &- 1) let newTTL = (ttl &- 1)
// Slight jitter to desynchronize without adding too much latency // Slight jitter to desynchronize without adding too much latency
let delayRange: ClosedRange<Int> = isHandshake ? 20...60 : 40...120 // Tighter for faster multi-hop handshakes and directed DMs
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
let delayMs = Int.random(in: delayRange) let delayMs = Int.random(in: delayRange)
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
} }
@@ -42,15 +44,22 @@ struct RelayController {
let prob = baseProb let prob = baseProb
let shouldRelay = Double.random(in: 0...1) <= prob let shouldRelay = Double.random(in: 0...1) <= prob
// TTL clamping in dense graphs (only for broadcast) // TTL clamping for broadcast
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5 // - Dense graphs: keep very low to avoid floods
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
// - Announces in sparse graphs get a bit more headroom
let ttlCap: UInt8 = {
if degree >= highDegreeThreshold { return 3 }
return isAnnounce ? 7 : 6
}()
let clamped = max(1, min(ttl, ttlCap)) let clamped = max(1, min(ttl, ttlCap))
let newTTL = clamped &- 1 let newTTL = clamped &- 1
// Wider jitter window to allow duplicate suppression to win more often // Wider jitter window to allow duplicate suppression to win more often
// For sparse graphs (<=2), relay quickly to avoid cancellation races
let delayMs: Int let delayMs: Int
switch degree { switch degree {
case 0...2: delayMs = Int.random(in: 40...100) case 0...2: delayMs = Int.random(in: 10...40)
case 3...5: delayMs = Int.random(in: 60...150) case 3...5: delayMs = Int.random(in: 60...150)
case 6...9: delayMs = Int.random(in: 80...180) case 6...9: delayMs = Int.random(in: 80...180)
default: delayMs = Int.random(in: 100...220) default: delayMs = Int.random(in: 100...220)
+1
View File
@@ -29,6 +29,7 @@ protocol Transport: AnyObject {
// Connectivity and peers // Connectivity and peers
func isPeerConnected(_ peerID: String) -> Bool func isPeerConnected(_ peerID: String) -> Bool
func isPeerReachable(_ peerID: String) -> Bool
func peerNickname(peerID: String) -> String? func peerNickname(peerID: String) -> String?
func getPeerNicknames() -> [String: String] func getPeerNicknames() -> [String: String]
+42 -11
View File
@@ -62,7 +62,7 @@ enum TransportConfig {
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60 static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
// BLE maintenance & thresholds // BLE maintenance & thresholds
static let bleMaintenanceInterval: TimeInterval = 10.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 = 60
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60 static let bleRecentTimeoutWindowSeconds: TimeInterval = 60
@@ -71,28 +71,43 @@ enum TransportConfig {
static let bleRSSIIsolatedRelaxed: Int = -92 static let bleRSSIIsolatedRelaxed: Int = -92
static let bleRSSIConnectedThreshold: Int = -85 static let bleRSSIConnectedThreshold: Int = -85
static let bleRSSIHighTimeoutThreshold: Int = -80 static let bleRSSIHighTimeoutThreshold: Int = -80
static let blePeerInactivityTimeoutSeconds: TimeInterval = 20.0 // 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
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
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
static let bleRecentPacketWindowSeconds: TimeInterval = 30.0 static let bleRecentPacketWindowSeconds: TimeInterval = 30.0
static let bleRecentPacketWindowMaxCount: Int = 100 static let bleRecentPacketWindowMaxCount: Int = 100
// Keep scanning fully ON when we saw traffic very recently
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05 static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
static let bleExpectedWritePerFragmentMs: Int = 8 static let bleExpectedWritePerFragmentMs: Int = 8
static let bleExpectedWriteMaxMs: Int = 2000 static let bleExpectedWriteMaxMs: Int = 2000
static let bleFragmentSpacingMs: Int = 6 // Faster fragment pacing; use slightly tighter spacing for directed trains
static let bleAnnounceIntervalSeconds: TimeInterval = 10.0 static let bleFragmentSpacingMs: Int = 5
static let bleFragmentSpacingDirectedMs: Int = 4
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
static let bleDutyOnDurationDense: TimeInterval = 3.0 static let bleDutyOnDurationDense: TimeInterval = 3.0
static let bleDutyOffDurationDense: TimeInterval = 15.0 static let bleDutyOffDurationDense: TimeInterval = 15.0
static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 90.0 static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 30.0
static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 45.0 static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 15.0
static let bleConnectedAnnounceJitterDense: TimeInterval = 20.0 static let bleConnectedAnnounceJitterDense: TimeInterval = 8.0
static let bleConnectedAnnounceJitterSparse: TimeInterval = 7.5 static let bleConnectedAnnounceJitterSparse: TimeInterval = 4.0
// Location // Location
static let locationDistanceFilterMeters: Double = 1000 static let locationDistanceFilterMeters: Double = 1000
// Live (channel sheet open) distance threshold for meaningful updates
static let locationDistanceFilterLiveMeters: Double = 21.0
static let locationLiveRefreshInterval: TimeInterval = 5.0 static let locationLiveRefreshInterval: TimeInterval = 5.0
// Notifications (geohash)
static let uiGeoNotifyCooldownSeconds: TimeInterval = 60.0
static let uiGeoNotifySnippetMaxLen: Int = 80
// Nostr geohash // Nostr geohash
static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600 static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600
static let nostrGeohashInitialLimit: Int = 200 static let nostrGeohashInitialLimit: Int = 200
@@ -126,12 +141,24 @@ enum TransportConfig {
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24 static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
// BLE operational delays // BLE operational delays
static let bleInitialAnnounceDelaySeconds: TimeInterval = 2.0 static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
static let bleConnectTimeoutSeconds: TimeInterval = 8.0 static let bleConnectTimeoutSeconds: TimeInterval = 8.0
static let bleRestartScanDelaySeconds: TimeInterval = 0.1 static let bleRestartScanDelaySeconds: TimeInterval = 0.1
static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.1 static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.05
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4 static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.2 static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
// Store-and-forward for directed packets at relays
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
// Log/UI debounce windows
// Shorter debounce so UI reacts faster while still suppressing duplicate callbacks
static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9
static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0
// Weak-link cooldown after connection timeouts
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
static let bleWeakLinkRSSICutoff: Int = -90
// Content hashing / formatting // Content hashing / formatting
static let contentKeyPrefixLength: Int = 256 static let contentKeyPrefixLength: Int = 256
@@ -148,6 +175,10 @@ enum TransportConfig {
// UI color tuning // UI color tuning
static let uiColorHueAvoidanceDelta: Double = 0.05 static let uiColorHueAvoidanceDelta: Double = 0.05
static let uiColorHueOffset: Double = 0.12 static let uiColorHueOffset: Double = 0.12
// Peer list palette
static let uiPeerPaletteSlots: Int = 36
static let uiPeerPaletteRingBrightnessDeltaLight: Double = 0.07
static let uiPeerPaletteRingBrightnessDeltaDark: Double = -0.07
// UI windowing (infinite scroll) // UI windowing (infinite scroll)
static let uiWindowInitialCountPublic: Int = 300 static let uiWindowInitialCountPublic: Int = 300
+36 -24
View File
@@ -68,24 +68,28 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private func updatePeers() { private func updatePeers() {
let meshPeers = meshService.currentPeerSnapshots() let meshPeers = meshService.currentPeerSnapshots()
// If we have no direct links at all, peers should not be marked reachable
// "Reachable" means mesh-attached via at least one live link.
let hasAnyConnected = meshPeers.contains { $0.isConnected }
let favorites = favoritesService.favorites let favorites = favoritesService.favorites
var enrichedPeers: [BitchatPeer] = [] var enrichedPeers: [BitchatPeer] = []
var connected: Set<String> = [] var connected: Set<String> = []
var addedPeerIDs: Set<String> = [] var addedPeerIDs: Set<String> = []
// Phase 1: Add all connected mesh peers // Phase 1: Add all mesh peers (connected and reachable)
for peerInfo in meshPeers where peerInfo.isConnected { for peerInfo in meshPeers {
let peerID = peerInfo.id let peerID = peerInfo.id
guard peerID != meshService.myPeerID else { continue } // Never add self guard peerID != meshService.myPeerID else { continue } // Never add self
let peer = buildPeerFromMesh( let peer = buildPeerFromMesh(
peerInfo: peerInfo, peerInfo: peerInfo,
favorites: favorites favorites: favorites,
meshAttached: hasAnyConnected
) )
enrichedPeers.append(peer) enrichedPeers.append(peer)
connected.insert(peerID) if peer.isConnected { connected.insert(peerID) }
addedPeerIDs.insert(peerID) addedPeerIDs.insert(peerID)
// Update fingerprint cache // Update fingerprint cache
@@ -117,14 +121,12 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Phase 3: Sort peers // Phase 3: Sort peers
enrichedPeers.sort { lhs, rhs in enrichedPeers.sort { lhs, rhs in
// Connected first // Connectivity rank: connected > reachable > others
if lhs.isConnected != rhs.isConnected { func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) }
return lhs.isConnected let lr = rank(lhs), rr = rank(rhs)
} if lr != rr { return lr > rr }
// Then favorites // Then favorites inside same rank
if lhs.isFavorite != rhs.isFavorite { if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite }
return lhs.isFavorite
}
// Finally alphabetical // Finally alphabetical
return lhs.displayName < rhs.displayName return lhs.displayName < rhs.displayName
} }
@@ -145,8 +147,11 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
} }
// Phase 5: Update published properties // Phase 5: Filter out offline non-mutual peers and update published properties
self.peers = enrichedPeers let filtered = enrichedPeers.filter { p in
p.isConnected || p.isReachable || p.isMutualFavorite
}
self.peers = filtered
self.connectedPeerIDs = connected self.connectedPeerIDs = connected
self.favorites = favoritesList self.favorites = favoritesList
self.mutualFavorites = mutualsList self.mutualFavorites = mutualsList
@@ -162,14 +167,26 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private func buildPeerFromMesh( private func buildPeerFromMesh(
peerInfo: TransportPeerSnapshot, peerInfo: TransportPeerSnapshot,
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship] favorites: [Data: FavoritesPersistenceService.FavoriteRelationship],
meshAttached: Bool
) -> BitchatPeer { ) -> BitchatPeer {
// Determine reachability based on lastSeen and identity trust
let now = Date()
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
let isVerified = fingerprint.map { SecureIdentityStateManager.shared.isVerified(fingerprint: $0) } ?? false
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
// A peer is reachable if we recently saw them AND we are attached to the mesh
let withinRetention = now.timeIntervalSince(peerInfo.lastSeen) <= retention
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
var peer = BitchatPeer( var peer = BitchatPeer(
id: peerInfo.id, id: peerInfo.id,
noisePublicKey: peerInfo.noisePublicKey ?? Data(), noisePublicKey: peerInfo.noisePublicKey ?? Data(),
nickname: peerInfo.nickname, nickname: peerInfo.nickname,
lastSeen: peerInfo.lastSeen, lastSeen: peerInfo.lastSeen,
isConnected: true isConnected: peerInfo.isConnected,
isReachable: isReachable
) )
// Check for favorite status // Check for favorite status
@@ -216,7 +233,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
noisePublicKey: favorite.peerNoisePublicKey, noisePublicKey: favorite.peerNoisePublicKey,
nickname: favorite.peerNickname, nickname: favorite.peerNickname,
lastSeen: favorite.lastUpdated, lastSeen: favorite.lastUpdated,
isConnected: false isConnected: false,
isReachable: false
) )
peer.favoriteStatus = favorite peer.favoriteStatus = favorite
@@ -394,10 +412,4 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Helper Extensions // MARK: - Helper Extensions
extension Data { // Moved sha256Fingerprint() to BinaryEncodingUtils for reuse
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}
File diff suppressed because it is too large Load Diff
+49 -21
View File
@@ -1036,9 +1036,8 @@ struct ContentView: View {
case .mesh: case .mesh:
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
guard peer.id != viewModel.meshService.myPeerID else { return } guard peer.id != viewModel.meshService.myPeerID else { return }
let isMeshConnected = peer.isConnected if peer.isConnected { counts.mesh += 1; counts.others += 1 }
if isMeshConnected { counts.mesh += 1; counts.others += 1 } else if peer.isReachable { counts.others += 1 }
else if peer.isMutualFavorite { counts.others += 1 }
} }
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
@@ -1181,20 +1180,11 @@ struct ContentView: View {
@ViewBuilder @ViewBuilder
private func privateHeaderContent(for privatePeerID: String) -> some View { private func privateHeaderContent(for privatePeerID: String) -> some View {
// Prefer short (mesh) ID when mesh-connected (radio). Only use full Noise key when not connected (globe). // Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only.
let headerPeerID: String = { let headerPeerID: String = {
if privatePeerID.count == 16 { if PeerIDResolver.isNoiseKeyHex(privatePeerID) {
let isMeshConnected = viewModel.meshService.isPeerConnected(privatePeerID) || viewModel.connectedPeers.contains(privatePeerID) // Map stable Noise key to short ID if we know it (even if not directly connected)
if !isMeshConnected, let stable = viewModel.getNoiseKeyForShortID(privatePeerID) { if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short }
return stable
}
} else if privatePeerID.count == 64 {
// If we have a full Noise key and a corresponding short ID is currently mesh-connected, prefer short ID
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
if viewModel.meshService.isPeerConnected(short) || viewModel.connectedPeers.contains(short) {
return short
}
}
} }
return privatePeerID return privatePeerID
}() }()
@@ -1209,10 +1199,29 @@ struct ContentView: View {
return "#\(ch.geohash)/@\(disp)" return "#\(ch.geohash)/@\(disp)"
} }
} }
return peer?.displayName ?? // Try mesh/unified peer display
viewModel.meshService.peerNickname(peerID: headerPeerID) ?? if let name = peer?.displayName { return name }
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ?? // Try direct mesh nickname (connected-only)
"Unknown" if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
// Try favorite nickname by stable Noise key
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
!fav.peerNickname.isEmpty { return fav.peerNickname }
// Fallback: resolve from persisted social identity via fingerprint mapping
if PeerIDResolver.isShortID(headerPeerID) {
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let id = candidates.first,
let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname }
}
} else if PeerIDResolver.isNoiseKeyHex(headerPeerID), let keyData = Data(hexString: headerPeerID) {
let fp = keyData.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname }
}
}
return "Unknown"
}() }()
let isNostrAvailable: Bool = { let isNostrAvailable: Bool = {
guard let connectionState = peer?.connectionState else { guard let connectionState = peer?.connectionState else {
@@ -1247,6 +1256,12 @@ struct ContentView: View {
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundColor(textColor) .foregroundColor(textColor)
.accessibilityLabel("Connected via mesh") .accessibilityLabel("Connected via mesh")
case .meshReachable:
// point.3 filled icon for reachable via mesh (not directly connected)
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.system(size: 14))
.foregroundColor(textColor)
.accessibilityLabel("Reachable via mesh")
case .nostrAvailable: case .nostrAvailable:
// Purple globe for Nostr // Purple globe for Nostr
Image(systemName: "globe") Image(systemName: "globe")
@@ -1257,6 +1272,12 @@ struct ContentView: View {
// Should not happen for PM header, but handle gracefully // Should not happen for PM header, but handle gracefully
EmptyView() EmptyView()
} }
} else if viewModel.meshService.isPeerReachable(headerPeerID) {
// Fallback: reachable via mesh but not in current peer list
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.system(size: 14))
.foregroundColor(textColor)
.accessibilityLabel("Reachable via mesh")
} else if isNostrAvailable { } else if isNostrAvailable {
// Fallback to Nostr if peer not in list but is mutual favorite // Fallback to Nostr if peer not in list but is mutual favorite
Image(systemName: "globe") Image(systemName: "globe")
@@ -1275,7 +1296,14 @@ struct ContentView: View {
.font(.system(size: 16, weight: .medium, design: .monospaced)) .font(.system(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs) .foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
if !privatePeerID.hasPrefix("nostr_") { if !privatePeerID.hasPrefix("nostr_") {
let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID) // Use short peer ID if available for encryption status (sessions keyed by short ID)
let statusPeerID: String = {
if PeerIDResolver.isNoiseKeyHex(privatePeerID), let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
return short
}
return headerPeerID
}()
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
if let icon = encryptionStatus.icon { if let icon = encryptionStatus.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 14)) .font(.system(size: 14))
+22 -4
View File
@@ -41,9 +41,27 @@ struct FingerprintView: View {
.padding() .padding()
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
// Peer info // Prefer short mesh ID for session/encryption status
let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "Unknown" let statusPeerID: String = {
let encryptionStatus = viewModel.getEncryptionStatus(for: peerID) if PeerIDResolver.isNoiseKeyHex(peerID), let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
return peerID
}()
// Resolve a friendly name
let peerNickname: String = {
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
if PeerIDResolver.isNoiseKeyHex(peerID), let data = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
let fp = data.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname }
}
}
return "Unknown"
}()
// Accurate encryption state based on short ID session
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
HStack { HStack {
if let icon = encryptionStatus.icon { if let icon = encryptionStatus.icon {
@@ -74,7 +92,7 @@ struct FingerprintView: View {
.font(.system(size: 12, weight: .bold, design: .monospaced)) .font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
if let fingerprint = viewModel.getFingerprint(for: peerID) { if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
Text(formatFingerprint(fingerprint)) Text(formatFingerprint(fingerprint))
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
+4 -10
View File
@@ -83,24 +83,17 @@ struct LocationChannelsSheet: View {
} }
// Begin periodic refresh while sheet is open // Begin periodic refresh while sheet is open
manager.beginLiveRefresh() manager.beginLiveRefresh()
// Begin multi-channel sampling for counts // Geohash sampling is now managed by ChatViewModel globally
let ghs = manager.availableChannels.map { $0.geohash }
viewModel.beginGeohashSampling(for: ghs)
} }
.onDisappear { .onDisappear {
manager.endLiveRefresh() manager.endLiveRefresh()
viewModel.endGeohashSampling()
} }
.onChange(of: manager.permissionState) { newValue in .onChange(of: manager.permissionState) { newValue in
if newValue == LocationChannelManager.PermissionState.authorized { if newValue == LocationChannelManager.PermissionState.authorized {
manager.refreshChannels() manager.refreshChannels()
} }
} }
.onChange(of: manager.availableChannels) { newValue in .onChange(of: manager.availableChannels) { _ in }
// Keep sampling list in sync with available channels as they refresh live
let ghs = newValue.map { $0.geohash }
viewModel.beginGeohashSampling(for: ghs)
}
} }
private var channelList: some View { private var channelList: some View {
@@ -289,9 +282,10 @@ struct LocationChannelsSheet: View {
} }
private func meshCount() -> Int { private func meshCount() -> Int {
// Count mesh-connected OR mesh-reachable peers (exclude self)
let myID = viewModel.meshService.myPeerID let myID = viewModel.meshService.myPeerID
return viewModel.allPeers.reduce(0) { acc, peer in return viewModel.allPeers.reduce(0) { acc, peer in
if peer.id != myID && peer.isConnected { return acc + 1 } if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
return acc return acc
} }
} }
+13
View File
@@ -52,6 +52,11 @@ struct MeshPeerList: View {
Image(systemName: "antenna.radiowaves.left.and.right") Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 10)) .font(.system(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} else if peer.isReachable {
// Mesh-reachable (relayed): point.3 icon
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if peer.isMutualFavorite { } else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple) // Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe") Image(systemName: "globe")
@@ -110,6 +115,14 @@ struct MeshPeerList: View {
Spacer() Spacer()
// Unread message indicator for this peer
if !isMe, item.hasUnread {
Image(systemName: "envelope.fill")
.font(.system(size: 10))
.foregroundColor(.orange)
.help("New messages")
}
if !isMe { if !isMe {
Button(action: { onToggleFavorite(peer.id) }) { Button(action: { onToggleFavorite(peer.id) }) {
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star") Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")