Compare commits

..
Author SHA1 Message Date
jack be722aa170 Fix critical routing and buffer issues\n\n- Preserve unsent items in MessageRouter outbox (no data loss)\n- Drain and bound Nostr send queue; flush on relay connect\n- Cap BLE pending write buffers per peripheral to avoid OOM\n- Enable Nostr fallback for short peer IDs via favorites mapping\n- Route favorite notifications via MessageRouter (mesh/Nostr) 2025-09-04 21:20:49 +02:00
e79bcf531b Feature/screenshot privacy warning (#541)
* Screenshot privacy: warn on location sheet; gate screenshot broadcasts to chat only\n\n- Track sheet presentation in ChatViewModel\n- Show privacy alert when screenshot taken on location channels\n- Ignore screenshots for App Info and Location sheet for broadcast\n- ContentView wires sheet onAppear/onDisappear and presents alert\n- Fix location sheet subtitle wrapping: render as single Text to avoid orphaned bullets

* Fix duplicate messages after channel switch\n\n- Dedup on per-geohash append (skip if ID exists)\n- Dedup and sort timeline by timestamp when switching into a geohash\n- Keep content/layout unchanged; add lightweight debug logs for empty rows

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 14:37:17 +02:00
jack 3e5043f875 Harden text formatting against Unicode range mismatches
- Use NSString.length for all NSRange regex scans
- Guard string slicing when lastEnd > match.lowerBound
- Advance lastEnd safely to avoid backtracking overlaps
- Apply to formatMessageContent, formatMessage, formatMessageAsText, parseMentions
2025-08-28 09:22:34 +01:00
jack 11cd5527ed Fix crash in formatMessageAsText: use NSString length for NSRanges and guard slicing before matches to avoid invalid ranges with multi-byte content 2025-08-28 09:18:11 +01:00
cf1bfdac6b Geohash Bookmarks + Rising‑Edge Notifications + Friendly Names (#538)
* Add geohash bookmarks: persistence, sampling integration, and UI\n\n- Add GeohashBookmarksStore with UserDefaults persistence and toggle API\n- Sample union of regional + bookmarked geohashes for activity notifications\n- LocationChannelsSheet: bookmark icons on nearby rows and a Bookmarked section\n- Header toolbar: toggle bookmark for current geohash\n- Tests: GeohashBookmarksStoreTests for normalization and persistence\n\nRationale: Bookmarked geohashes are always sampled for new activity notifications and quickly selectable from the sheet.

* Notifications: remove background 'new chats!' nudge; geohash activity only on zero→alive; mesh 'nearby' only on zero→alive\n\n- Geohash sampling: notify only when previous participant count in last 5m was zero, with 30s freshness gate\n- Suppress old sampled events after (re)subscribe\n- Remove channel inactivity nudge\n- Mesh: reset rising-edge gate immediately when peer list goes empty (strict zero→alive)

* Geohash notifications: ensure triggering message appears\n\n- On zero→alive sampling, pre-populate geoTimelines[gh] with the triggering message\n- Dedup on per-geohash timeline and UI messages by message ID to avoid duplicates after subscribe\n- Keeps participant update, block/self checks, and cooldown intact

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 09:10:15 +01:00
b63a595b04 Fix geohash UX: rising-edge notify, correct teleport, sort timeline (#537)
- Remove "new chats!" background nudge; notify only when a regional geohash goes from 0 participants to activity, with content preview and cooldown.\n- Do not mark teleported when selected geohash is in regional channel list; clear persisted teleport when in-region; avoid self teleported state from historical tags; deep links don’t mark teleported for regional geohashes or during cold start.\n- Sort per-geohash timelines chronologically on re-entry and trim whitespace-only content to prevent blank lines.\n- Trim inbound public content and ignore whitespace-only sends.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 00:08:01 +01: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
24 changed files with 2057 additions and 423 deletions
+66 -16
View File
@@ -2,7 +2,7 @@
## 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)
@@ -11,50 +11,99 @@ A decentralized peer-to-peer messaging app that works over Bluetooth mesh networ
> [!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
+22 -13
View File
@@ -32,6 +32,10 @@
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; }; 049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; }; 049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; }; 049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
@@ -205,6 +209,8 @@
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; }; 047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; }; 047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; }; 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; };
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; };
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; }; 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; }; 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; }; 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
@@ -328,7 +334,6 @@
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */, A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
C3D98EB3E1B455E321F519F4 /* bitchatTests */, C3D98EB3E1B455E321F519F4 /* bitchatTests */,
9F37F9F2C353B58AC809E93B /* Products */, 9F37F9F2C353B58AC809E93B /* Products */,
048A4BE52E5CCC5C00162C4A /* Recovered References */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -484,6 +489,7 @@
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = { C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
D69A18D27F9A565FD6041E12 /* Info.plist */, D69A18D27F9A565FD6041E12 /* Info.plist */,
047502912E547ACC0083520F /* LocationChannelsTests.swift */, 047502912E547ACC0083520F /* LocationChannelsTests.swift */,
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */, C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
@@ -520,6 +526,7 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = { D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */, 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */, AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */, 047502B82E560F690083520F /* RelayController.swift */,
@@ -743,14 +750,13 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE72E5CCCC300162C4A /* TransportConfig.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 */,
@@ -773,6 +779,7 @@
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */, 0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */, AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */, 8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */,
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */, 049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */, D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */,
8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */, 8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */,
@@ -803,14 +810,13 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE82E5CCCC300162C4A /* TransportConfig.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 */,
@@ -833,6 +839,7 @@
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */, 0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */, 6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */, A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */, 049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */, 9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */, 92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
@@ -869,6 +876,7 @@
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */, 047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */, D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */, 6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */, 765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */, 968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
@@ -891,6 +899,7 @@
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */, 047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */, 8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */, 3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */, BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */, EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
@@ -1007,7 +1016,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 +1047,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 +1102,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 +1134,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 +1223,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 +1316,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?
} }
@@ -230,6 +230,91 @@ class SecureIdentityStateManager {
} }
} }
// 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 peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
}
}
func getAllSocialIdentities() -> [SocialIdentity] { func getAllSocialIdentities() -> [SocialIdentity] {
queue.sync { queue.sync {
return Array(cache.socialIdentities.values) return Array(cache.socialIdentities.values)
+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
+57 -7
View File
@@ -44,7 +44,12 @@ class NostrRelayManager: ObservableObject {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
// Message queue for reliability // Message queue for reliability
private var messageQueue: [(event: NostrEvent, relayUrls: [String])] = [] // Pending sends held only for relays that are not yet connected.
private struct PendingSend {
var event: NostrEvent
var pendingRelays: Set<String>
}
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
// Exponential backoff configuration // Exponential backoff configuration
@@ -96,15 +101,56 @@ class NostrRelayManager: ObservableObject {
let targetRelays = relayUrls ?? Self.defaultRelays let targetRelays = relayUrls ?? Self.defaultRelays
ensureConnections(to: targetRelays) ensureConnections(to: targetRelays)
// Add to queue for reliability // Attempt immediate send to relays with active connections; queue the rest
messageQueueLock.lock() var stillPending = Set<String>()
messageQueue.append((event, targetRelays))
messageQueueLock.unlock()
// Attempt immediate send
for relayUrl in targetRelays { for relayUrl in targetRelays {
if let connection = connections[relayUrl] { if let connection = connections[relayUrl] {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl) sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
} else {
stillPending.insert(relayUrl)
}
}
if !stillPending.isEmpty {
messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
messageQueueLock.unlock()
}
}
/// Try to flush any queued messages for relays that are now connected.
private func flushMessageQueue(for relayUrl: String? = nil) {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
guard !messageQueue.isEmpty else { return }
if let target = relayUrl {
// Flush only for a specific relay
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
if item.pendingRelays.contains(target), let conn = connections[target] {
sendToRelay(event: item.event, connection: conn, relayUrl: target)
item.pendingRelays.remove(target)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
} else {
messageQueue[i] = item
}
}
}
} else {
// Flush for any relays that now have connections
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
for url in item.pendingRelays {
if let conn = connections[url] {
sendToRelay(event: item.event, connection: conn, relayUrl: url)
item.pendingRelays.remove(url)
}
}
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
} else {
messageQueue[i] = item
}
} }
} }
} }
@@ -389,6 +435,10 @@ class NostrRelayManager: ObservableObject {
} }
} }
updateConnectionStatus() updateConnectionStatus()
// If we just connected to this relay, flush any queued sends targeting it
if isConnected {
flushMessageQueue(for: url)
}
} }
private func updateConnectionStatus() { private func updateConnectionStatus() {
+24
View File
@@ -87,4 +87,28 @@ enum Geohash {
let lon = (lonInterval.0 + lonInterval.1) / 2 let lon = (lonInterval.0 + lonInterval.1) / 2
return (lat, lon) return (lat, lon)
} }
/// Decodes a geohash into its latitude and longitude bounds.
/// - Parameter geohash: Base32 geohash string.
/// - Returns: (latMin, latMax, lonMin, lonMax)
static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) {
var latInterval: (Double, Double) = (-90.0, 90.0)
var lonInterval: (Double, Double) = (-180.0, 180.0)
var isEven = true
for ch in geohash.lowercased() {
guard let cd = base32Map[ch] else { continue }
for mask in [16, 8, 4, 2, 1] {
if isEven {
let mid = (lonInterval.0 + lonInterval.1) / 2
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
} else {
let mid = (latInterval.0 + latInterval.1) / 2
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
}
isEven.toggle()
}
}
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
}
} }
+447 -117
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
@@ -210,7 +223,30 @@ final class BLEService: NSObject {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse) peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} else { } else {
self.collectionsQueue.async(flags: .barrier) { self.collectionsQueue.async(flags: .barrier) {
self.pendingPeripheralWrites[uuid, default: []].append(data) var queue = self.pendingPeripheralWrites[uuid] ?? []
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
let newSize = data.count
// If single chunk exceeds cap, drop it immediately
if newSize > capBytes {
SecureLogger.log("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)",
category: SecureLogger.session, level: .warning)
} else {
// Append and trim from the front to respect cap
var total = queue.reduce(0) { $0 + $1.count }
queue.append(data)
total += newSize
if total > capBytes {
var removedBytes = 0
while total > capBytes && !queue.isEmpty {
let removed = queue.removeFirst()
removedBytes += removed.count
total -= removed.count
}
SecureLogger.log("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B",
category: SecureLogger.session, level: .warning)
}
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
}
} }
} }
} }
@@ -307,6 +343,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)
} }
} }
@@ -502,6 +543,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 peerID.count == 64, 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 +603,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
}
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", if noiseService.hasEstablishedSession(with: peerID) {
category: SecureLogger.session, level: .debug) SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)",
category: SecureLogger.session, level: .debug)
// Create read receipt payload: [type byte] + [message ID] do {
var receiptPayload = Data([NoisePayloadType.readReceipt.rawValue]) let encrypted = try noiseService.encrypt(payload, for: peerID)
receiptPayload.append(contentsOf: receipt.originalMessageID.utf8) let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
do { senderID: Data(hexString: myPeerID) ?? Data(),
let encrypted = try noiseService.encrypt(receiptPayload, for: peerID) recipientID: Data(hexString: peerID),
let packet = BitchatPacket( timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
type: MessageType.noiseEncrypted.rawValue, payload: encrypted,
senderID: Data(hexString: myPeerID) ?? Data(), signature: nil,
recipientID: Data(hexString: peerID), ttl: messageTTL
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), )
payload: encrypted, if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
signature: nil, broadcastPacket(packet)
ttl: messageTTL } else {
) messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
// 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)
} }
} }
@@ -697,19 +757,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 +1104,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
@@ -1063,6 +1138,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
guard peripheral.state == .connected else { return } guard peripheral.state == .connected else { return }
@@ -1127,7 +1253,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 +1349,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 +1415,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 +1466,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 +1511,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 +1522,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,13 +1530,49 @@ 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)
}
} }
} }
@@ -1412,8 +1583,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 +1604,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
@@ -1441,8 +1620,40 @@ final class BLEService: NSObject {
// 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,16 +1662,16 @@ 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
@@ -1659,31 +1870,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] private func sendPendingNoisePayloadsAfterHandshake(for peerID: String) {
var ackPayload = Data([NoisePayloadType.delivered.rawValue]) let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
ackPayload.append(contentsOf: messageID.utf8) let list = pendingNoisePayloadsAfterHandshake[peerID] ?? []
pendingNoisePayloadsAfterHandshake.removeValue(forKey: peerID)
do { return list
let encrypted = try noiseService.encrypt(ackPayload, for: peerID) }
let packet = BitchatPacket( guard !payloads.isEmpty else { return }
type: MessageType.noiseEncrypted.rawValue, SecureLogger.log("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake",
senderID: Data(hexString: myPeerID) ?? Data(), category: SecureLogger.session, level: .debug)
recipientID: Data(hexString: peerID), for payload in payloads {
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), do {
payload: encrypted, let encrypted = try noiseService.encrypt(payload, for: peerID)
signature: nil, let packet = BitchatPacket(
ttl: messageTTL type: MessageType.noiseEncrypted.rawValue,
) senderID: Data(hexString: myPeerID) ?? Data(),
broadcastPacket(packet) recipientID: Data(hexString: peerID),
// Delivery ACK sent timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
} catch { payload: encrypted,
SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error) signature: nil,
ttl: messageTTL
)
broadcastPacket(packet)
} catch {
SecureLogger.log("❌ Failed to send pending noise payload to \(peerID): \(error)",
category: SecureLogger.noise, level: .error)
}
} }
} }
@@ -1697,6 +1941,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() {
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync { let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
@@ -1749,6 +2005,16 @@ final class BLEService: NSObject {
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 {
// Ensure we're advertising as peripheral // Ensure we're advertising as peripheral
@@ -1761,16 +2027,19 @@ 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
// Reset counter to prevent overflow (every 60 seconds) // Reset counter to prevent overflow (every 60 seconds)
@@ -1783,28 +2052,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 +2094,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 +2139,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 +2159,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
@@ -2138,6 +2435,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 +2447,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 +2467,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 +2475,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 +2527,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 +2672,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 +2838,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 +2858,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 +2876,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)
} }
} }
@@ -0,0 +1,227 @@
import Foundation
import Combine
#if os(iOS) || os(macOS)
import CoreLocation
#endif
/// Stores a user-maintained list of bookmarked geohash channels.
/// - Persistence: UserDefaults (JSON string array)
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
final class GeohashBookmarksStore: ObservableObject {
static let shared = GeohashBookmarksStore()
@Published private(set) var bookmarks: [String] = []
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
private let storeKey = "locationChannel.bookmarks"
private let namesStoreKey = "locationChannel.bookmarkNames"
private var membership: Set<String> = []
#if os(iOS) || os(macOS)
private let geocoder = CLGeocoder()
private var resolving: Set<String> = []
#endif
private init() {
load()
}
// MARK: - Public API
func isBookmarked(_ geohash: String) -> Bool {
return membership.contains(Self.normalize(geohash))
}
func toggle(_ geohash: String) {
let gh = Self.normalize(geohash)
if membership.contains(gh) {
remove(gh)
} else {
add(gh)
}
}
func add(_ geohash: String) {
let gh = Self.normalize(geohash)
guard !gh.isEmpty else { return }
guard !membership.contains(gh) else { return }
bookmarks.insert(gh, at: 0)
membership.insert(gh)
persist()
// Resolve and persist a friendly name once when added
resolveNameIfNeeded(for: gh)
}
func remove(_ geohash: String) {
let gh = Self.normalize(geohash)
guard membership.contains(gh) else { return }
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
membership.remove(gh)
// Clean up stored name to avoid stale cache growth
if bookmarkNames.removeValue(forKey: gh) != nil {
persistNames()
}
persist()
}
// MARK: - Persistence
private func load() {
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
if let arr = try? JSONDecoder().decode([String].self, from: data) {
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
var seen = Set<String>()
var list: [String] = []
for raw in arr {
let gh = Self.normalize(raw)
guard !gh.isEmpty else { continue }
if !seen.contains(gh) {
seen.insert(gh)
list.append(gh)
}
}
bookmarks = list
membership = seen
}
// Load any saved names
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
bookmarkNames = dict
}
}
private func persist() {
if let data = try? JSONEncoder().encode(bookmarks) {
UserDefaults.standard.set(data, forKey: storeKey)
}
}
private func persistNames() {
if let data = try? JSONEncoder().encode(bookmarkNames) {
UserDefaults.standard.set(data, forKey: namesStoreKey)
}
}
// MARK: - Helpers
private static func normalize(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: "#", with: "")
.filter { allowed.contains($0) }
}
// MARK: - Name Resolution
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
func resolveNameIfNeeded(for geohash: String) {
let gh = Self.normalize(geohash)
guard !gh.isEmpty else { return }
if bookmarkNames[gh] != nil { return }
#if os(iOS) || os(macOS)
if resolving.contains(gh) { return }
resolving.insert(gh)
// For very coarse geohashes, sample multiple points to capture multiple admin areas
if gh.count <= 2 {
let b = Geohash.decodeBounds(gh)
let pts: [CLLocation] = [
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
CLLocation(latitude: b.latMin, longitude: b.lonMin),
CLLocation(latitude: b.latMin, longitude: b.lonMax),
CLLocation(latitude: b.latMax, longitude: b.lonMin),
CLLocation(latitude: b.latMax, longitude: b.lonMax)
]
resolveCompositeAdminName(geohash: gh, points: pts)
} else {
let center = Geohash.decodeCenter(gh)
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard let self = self else { return }
defer { self.resolving.remove(gh) }
if let pm = placemarks?.first {
let name = Self.nameForGeohashLength(gh.count, from: pm)
if let name = name, !name.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = name
self.persistNames()
}
}
}
}
}
#endif
}
#if os(iOS) || os(macOS)
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
var uniqueAdmins = OrderedSet<String>()
var idx = 0
func step() {
if idx >= points.count {
// Compose up to 2 names joined by ' and '
let finalName: String? = {
let names = uniqueAdmins.array
if names.count >= 2 { return names[0] + " and " + names[1] }
return names.first
}()
if let finalName = finalName, !finalName.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = finalName
self.persistNames()
}
}
self.resolving.remove(gh)
return
}
let loc = points[idx]
idx += 1
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard self != nil else { return }
if let pm = placemarks?.first {
if let admin = pm.administrativeArea, !admin.isEmpty {
uniqueAdmins.insert(admin)
} else if let country = pm.country, !country.isEmpty {
uniqueAdmins.insert(country)
}
}
// Proceed to next point
step()
}
}
step()
}
// Minimal ordered-set for stable joining
private struct OrderedSet<Element: Hashable> {
private var set: Set<Element> = []
private(set) var array: [Element] = []
mutating func insert(_ element: Element) {
if set.insert(element).inserted { array.append(element) }
}
}
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
switch len {
case 0...2:
// Prefer administrative area if available at this coarse level
return pm.administrativeArea ?? pm.country
case 3...4:
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
case 5:
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
case 6...7:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
default:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
}
}
#endif
#if DEBUG
/// Testing-only reset helper
func _resetForTesting() {
bookmarks.removeAll()
membership.removeAll()
bookmarkNames.removeAll()
persist()
persistNames()
}
#endif
}
+55 -16
View File
@@ -50,10 +50,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
let arr = try? JSONDecoder().decode([String].self, from: data) { let arr = try? JSONDecoder().decode([String].self, from: data) {
teleportedSet = Set(arr) teleportedSet = Set(arr)
} }
// Initialize teleported flag from persisted state if a location channel is selected // Do not eagerly mark teleported on startup; wait for location to compute regional set.
if case .location(let ch) = selectedChannel { // This avoids showing teleported for in-region channels during cold start.
teleported = teleportedSet.contains(ch.geohash)
}
let status: CLAuthorizationStatus let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) { if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus status = cl.authorizationStatus
@@ -61,6 +59,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
status = CLLocationManager.authorizationStatus() status = CLLocationManager.authorizationStatus()
} }
updatePermissionState(from: status) updatePermissionState(from: status)
// If we don't have location authorization at startup, fall back to persisted teleport state
switch status {
case .authorizedAlways, .authorizedWhenInUse, .authorized:
break // will compute from location
default:
if case .location(let ch) = selectedChannel {
teleported = teleportedSet.contains(ch.geohash)
}
}
} }
// MARK: - Public API // MARK: - Public API
@@ -92,23 +99,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) {
@@ -122,7 +136,21 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
case .mesh: case .mesh:
self.teleported = false self.teleported = false
case .location(let ch): case .location(let ch):
self.teleported = self.teleportedSet.contains(ch.geohash) // If this geohash is in our current regional set, do NOT mark teleported.
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
// Clear persisted teleport for this geohash to keep future selections clean
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
}
}
} else {
// Fall back to persisted mark (set by deep link or manual teleport)
self.teleported = self.teleportedSet.contains(ch.geohash)
}
} }
} }
} }
@@ -195,14 +223,25 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} }
Task { @MainActor in Task { @MainActor in
self.availableChannels = result self.availableChannels = result
// Recompute teleported status based on persisted state OR current location vs selected channel // Recompute teleported status based on whether the selected geohash is in our regional set
switch self.selectedChannel { switch self.selectedChannel {
case .mesh: case .mesh:
self.teleported = false self.teleported = false
case .location(let ch): case .location(let ch):
let persisted = self.teleportedSet.contains(ch.geohash) // Membership check using freshly computed regional channels; avoids precision/rename drift
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision) let inRegional = result.contains { $0.geohash == ch.geohash }
self.teleported = persisted || (currentGH != ch.geohash) if inRegional {
self.teleported = false
// Clear persisted teleport flag if present
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
}
}
} else {
self.teleported = true
}
} }
} }
} }
+34 -17
View File
@@ -37,11 +37,11 @@ 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))",
@@ -57,9 +57,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 +70,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)
@@ -87,10 +89,19 @@ final class MessageRouter {
// MARK: - Outbox Management // MARK: - Outbox Management
private func canSendViaNostr(peerID: String) -> Bool { private func canSendViaNostr(peerID: String) -> Bool {
guard let noiseKey = Data(hexString: peerID) else { return false } // Two forms are supported:
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), // - 64-hex Noise public key (32 bytes)
fav.peerNostrPublicKey != nil { // - 16-hex short peer ID (derived from Noise pubkey)
return true if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
fav.peerNostrPublicKey != nil {
return true
}
} else if peerID.count == 16 {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
fav.peerNostrPublicKey != nil {
return true
}
} }
return false return false
} }
@@ -99,9 +110,10 @@ final class MessageRouter {
guard let queued = outbox[peerID], !queued.isEmpty else { return } guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
var remaining: [(content: String, nickname: String, messageID: String)] = []
// 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)
@@ -110,14 +122,19 @@ final class MessageRouter {
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else { } else {
continue // Keep unsent items queued
remaining.append((content, nickname, messageID))
} }
} }
// Remove all flushed items (remaining ones, if any, will be re-queued on next call) // Persist only items we could not send
outbox[peerID]?.removeAll() if remaining.isEmpty {
outbox.removeValue(forKey: peerID)
} else {
outbox[peerID] = remaining
}
} }
func flushAllOutbox() { func flushAllOutbox() {
for key in outbox.keys { flushOutbox(for: key) } for key in Array(outbox.keys) { flushOutbox(for: key) }
} }
} }
+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] { [:] }
@@ -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"
+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
+43 -19
View File
@@ -27,6 +27,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private var peerIndex: [String: BitchatPeer] = [:] private var peerIndex: [String: BitchatPeer] = [:]
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
private let meshService: Transport private let meshService: Transport
weak var messageRouter: MessageRouter?
private let favoritesService = FavoritesPersistenceService.shared private let favoritesService = FavoritesPersistenceService.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -68,24 +69,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 +122,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 +148,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 +168,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 +234,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
@@ -312,8 +331,13 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
// Send favorite notification to the peer // Send favorite notification to the peer via router (mesh or Nostr)
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite) if let router = messageRouter {
router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
} else {
// Fallback to mesh-only if router not yet wired
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
}
// Force update of peers to reflect the change // Force update of peers to reflect the change
updatePeers() updatePeers()
File diff suppressed because it is too large Load Diff
+78 -23
View File
@@ -24,6 +24,7 @@ struct ContentView: View {
@EnvironmentObject var viewModel: ChatViewModel @EnvironmentObject var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared @ObservedObject private var locationManager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@State private var messageText = "" @State private var messageText = ""
@State private var textFieldSelection: NSRange? = nil @State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool @FocusState private var isTextFieldFocused: Bool
@@ -164,6 +165,8 @@ struct ContentView: View {
} }
.sheet(isPresented: $showAppInfo) { .sheet(isPresented: $showAppInfo) {
AppInfoView() AppInfoView()
.onAppear { viewModel.isAppInfoPresented = true }
.onDisappear { viewModel.isAppInfoPresented = false }
} }
.sheet(isPresented: Binding( .sheet(isPresented: Binding(
get: { viewModel.showingFingerprintFor != nil }, get: { viewModel.showingFingerprintFor != nil },
@@ -278,8 +281,10 @@ struct ContentView: View {
} }
}() }()
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) } let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
// Filter out empty/whitespace-only messages to avoid blank rows
let filteredItems = items.filter { !$0.message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
ForEach(items, id: \.uiID) { item in ForEach(filteredItems, id: \.uiID) { item in
let message = item.message let message = item.message
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Check if current user is mentioned // Check if current user is mentioned
@@ -459,7 +464,13 @@ struct ContentView: View {
} }
let level = levelForLength(gh.count) let level = levelForLength(gh.count)
let ch = GeohashChannel(level: level, geohash: gh) let ch = GeohashChannel(level: level, geohash: gh)
LocationChannelManager.shared.markTeleported(for: gh, true) // Do not mark teleported when opening a geohash that is in our regional set.
// If availableChannels is empty (e.g., cold start), defer marking and let
// LocationChannelManager compute teleported based on actual location.
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.markTeleported(for: gh, true)
}
LocationChannelManager.shared.select(ChannelID.location(ch)) LocationChannelManager.shared.select(ChannelID.location(ch))
} }
.onTapGesture(count: 3) { .onTapGesture(count: 3) {
@@ -1036,9 +1047,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
@@ -1113,6 +1123,15 @@ struct ContentView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Open unread private chat") .accessibilityLabel("Open unread private chat")
} }
// Bookmark toggle for current geohash (not shown for mesh)
if case .location(let ch) = locationManager.selectedChannel {
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
.font(.system(size: 12))
}
.buttonStyle(.plain)
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
}
// Location channels button '#' // Location channels button '#'
Button(action: { showLocationChannelsSheet = true }) { Button(action: { showLocationChannelsSheet = true }) {
let badgeText: String = { let badgeText: String = {
@@ -1167,6 +1186,13 @@ struct ContentView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.sheet(isPresented: $showLocationChannelsSheet) { .sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet) LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
}
.alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) {
Button("ok", role: .cancel) {}
} message: {
Text("screenshots of location channels will reveal your location. think before sharing publicly.")
} }
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
} }
@@ -1181,20 +1207,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 privatePeerID.count == 64 {
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 +1226,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 headerPeerID.count == 16 {
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 headerPeerID.count == 64, 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 +1283,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 +1299,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 +1323,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 privatePeerID.count == 64, 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 peerID.count == 64, 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 peerID.count == 64, 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)
+102 -37
View File
@@ -8,6 +8,7 @@ import AppKit
struct LocationChannelsSheet: View { struct LocationChannelsSheet: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@ObservedObject private var manager = LocationChannelManager.shared @ObservedObject private var manager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@EnvironmentObject var viewModel: ChatViewModel @EnvironmentObject var viewModel: ChatViewModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@State private var customGeohash: String = "" @State private var customGeohash: String = ""
@@ -83,29 +84,22 @@ 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 {
List { List {
// Mesh option first // Mesh option first (no bookmark)
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) { channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
manager.select(ChannelID.mesh) manager.select(ChannelID.mesh)
isPresented = false isPresented = false
@@ -119,7 +113,21 @@ struct LocationChannelsSheet: View {
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 } let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
let subtitlePrefix = "#\(channel.geohash)\(coverage)" let subtitlePrefix = "#\(channel.geohash)\(coverage)"
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0 let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) { channelRow(
title: geohashTitleWithCount(for: channel),
subtitlePrefix: subtitlePrefix,
subtitleName: namePart,
isSelected: isSelected(channel),
titleBold: highlight,
trailingAccessory: {
Button(action: { bookmarks.toggle(channel.geohash) }) {
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
// Selecting a suggested nearby channel is not a teleport. Persist this. // Selecting a suggested nearby channel is not a teleport. Persist this.
manager.markTeleported(for: channel.geohash, false) manager.markTeleported(for: channel.geohash, false)
manager.select(ChannelID.location(channel)) manager.select(ChannelID.location(channel))
@@ -195,6 +203,48 @@ struct LocationChannelsSheet: View {
} }
} }
// Bookmarked geohashes
if !bookmarks.bookmarks.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("bookmarked")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
}
.listRowSeparator(.hidden)
ForEach(bookmarks.bookmarks, id: \.self) { gh in
let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh)
let coverage = coverageString(forPrecision: gh.count)
let subtitle = "#\(gh)\(coverage)"
let name = bookmarks.bookmarkNames[gh]
channelRow(
title: geohashHashTitleWithCount(gh),
subtitlePrefix: subtitle,
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
isSelected: isSelected(channel),
trailingAccessory: {
Button(action: { bookmarks.toggle(gh) }) {
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
// For bookmarked selection, mark teleported based on regional membership
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
if !inRegional && !manager.availableChannels.isEmpty {
manager.markTeleported(for: gh, true)
} else {
manager.markTeleported(for: gh, false)
}
manager.select(ChannelID.location(channel))
isPresented = false
}
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
}
}
// Footer action inside the list // Footer action inside the list
if manager.permissionState == LocationChannelManager.PermissionState.authorized { if manager.permissionState == LocationChannelManager.PermissionState.authorized {
Button(action: { Button(action: {
@@ -227,14 +277,24 @@ struct LocationChannelsSheet: View {
return false return false
} }
private func channelRow(title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, action: @escaping () -> Void) -> some View { @ViewBuilder
Button(action: action) { private func channelRow(
HStack { title: String,
VStack(alignment: .leading) { subtitlePrefix: String,
// Render title with smaller font for trailing count in parentheses subtitleName: String? = nil,
let parts = splitTitleAndCount(title) subtitleNameBold: Bool = false,
HStack(spacing: 4) { isSelected: Bool,
Text(parts.base) titleColor: Color? = nil,
titleBold: Bool = false,
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
action: @escaping () -> Void
) -> some View {
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading) {
// Render title with smaller font for trailing count in parentheses
let parts = splitTitleAndCount(title)
HStack(spacing: 4) {
Text(parts.base)
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.fontWeight(titleBold ? .bold : .regular) .fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary) .foregroundColor(titleColor ?? Color.primary)
@@ -244,20 +304,17 @@ struct LocationChannelsSheet: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
HStack(spacing: 0) { let subtitleFull: String = {
Text(subtitlePrefix) if let name = subtitleName, !name.isEmpty {
.font(.system(size: 12, design: .monospaced)) return subtitlePrefix + "" + name
.foregroundColor(.secondary)
if let name = subtitleName {
Text("")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Text(name)
.font(.system(size: 12, design: .monospaced))
.fontWeight(subtitleNameBold ? .bold : .regular)
.foregroundColor(.secondary)
}
} }
return subtitlePrefix
}()
Text(subtitleFull)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
} }
Spacer() Spacer()
if isSelected { if isSelected {
@@ -265,11 +322,11 @@ struct LocationChannelsSheet: View {
.font(.system(size: 16, design: .monospaced)) .font(.system(size: 16, design: .monospaced))
.foregroundColor(standardGreen) .foregroundColor(standardGreen)
} }
trailingAccessory()
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
} .onTapGesture(perform: action)
.buttonStyle(.plain)
} }
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]" // Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
@@ -289,20 +346,28 @@ 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
} }
} }
private func geohashTitleWithCount(for channel: GeohashChannel) -> String { private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
// Use ViewModel's 5-minute activity counts; may be 0 for non-selected channels // Main list: keep level labels (block/neighborhood/city/province/region)
let count = viewModel.geohashParticipantCount(for: channel.geohash) let count = viewModel.geohashParticipantCount(for: channel.geohash)
let noun = count == 1 ? "person" : "people" let noun = count == 1 ? "person" : "people"
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]" return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
} }
private func geohashHashTitleWithCount(_ geohash: String) -> String {
// Bookmarked list: show the #geohash as the main label
let count = viewModel.geohashParticipantCount(for: geohash)
let noun = count == 1 ? "person" : "people"
return "#\(geohash) [\(count) \(noun)]"
}
private func validateGeohash(_ s: String) -> Bool { private func validateGeohash(_ s: String) -> Bool {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard !s.isEmpty, s.count <= 12 else { return false } guard !s.isEmpty, s.count <= 12 else { return false }
+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")
@@ -0,0 +1,51 @@
import XCTest
@testable import bitchat
final class GeohashBookmarksStoreTests: XCTestCase {
let storeKey = "locationChannel.bookmarks"
override func setUp() {
super.setUp()
// Clear persisted state before each test
UserDefaults.standard.removeObject(forKey: storeKey)
GeohashBookmarksStore.shared._resetForTesting()
}
override func tearDown() {
// Clean after each test
UserDefaults.standard.removeObject(forKey: storeKey)
GeohashBookmarksStore.shared._resetForTesting()
super.tearDown()
}
func testToggleAndNormalize() {
let store = GeohashBookmarksStore.shared
// Start clean
XCTAssertTrue(store.bookmarks.isEmpty)
// Add with mixed case and hash prefix
store.toggle("#U4PRUY")
XCTAssertTrue(store.isBookmarked("u4pruy"))
XCTAssertEqual(store.bookmarks.first, "u4pruy")
// Toggling again removes
store.toggle("u4pruy")
XCTAssertFalse(store.isBookmarked("u4pruy"))
XCTAssertTrue(store.bookmarks.isEmpty)
}
func testPersistenceWritten() throws {
let store = GeohashBookmarksStore.shared
store.toggle("ezs42")
store.toggle("u4pruy")
// Verify persisted JSON contains both (order not enforced here)
guard let data = UserDefaults.standard.data(forKey: storeKey) else {
XCTFail("No persisted data found")
return
}
let arr = try JSONDecoder().decode([String].self, from: data)
XCTAssertTrue(arr.contains("ezs42"))
XCTAssertTrue(arr.contains("u4pruy"))
}
}