Refactor/ble nostr boundaries (#449)

* Refactor: move Nostr embedding and TLVs out of BLE; add NostrEmbeddedBitChat, Packets, PeerIDUtils; centralize MessageDeduplicator; update ChatViewModel to use new helpers; remove AI_CONTEXT.md and CLAUDE.md

* Rename class to BLEService with compatibility alias; move mention parsing out of BLE; emit low-level BLE events to delegate; unify hex helpers; accept 64-hex in isPeerConnected; add PeerIDResolver

* Project: rename file to BLEService.swift and update Xcode project; keep typealias SimplifiedBluetoothService = BLEService for compatibility

* Remove SimplifiedBluetoothService alias; update app code to use BLEService explicitly

* Tests: rename MockSimplifiedBluetoothService to MockBLEService; update typealiases and Xcode project

* Docs: update comments to refer to BLEService (tests, protocol, noise service)

* Tests: rename SimplifiedBluetoothServiceTests to BLEServiceTests; update project references and class names

* Introduce Transport protocol; BLEService conforms; document delegate-only event pattern in BLEService; keep publishers internal for UnifiedPeerService

* Adopt Transport end-to-end: add TransportPeerSnapshot + publishers; BLEService maps to Transport snapshots; UnifiedPeerService consumes Transport; ChatViewModel holds Transport

* Fix Transport integration: replace getPeerFingerprint with getFingerprint(for:); update PrivateChatManager and CommandProcessor to use Transport; add BLEService.getFingerprint(for:); update PeerManager to use Transport

* Refactor transport and BLE/Nostr layers; unify UI events; fix MainActor isolation

- Rename SimplifiedBluetoothService to BLEService and slim responsibilities
- Introduce Transport protocol and peerEventsDelegate for UI updates
- Add NostrTransport and MessageRouter to route PM/read/favorite via BLE or Nostr
- Centralize TLVs, PeerID utils, and MessageDeduplicator outside BLE
- Update UnifiedPeerService and ChatViewModel to use Transport and delegate events
- Fix MainActor isolation: route delegate calls via Task on MainActor; update notifyUI helper
- Adjust related files and tests accordingly

* BLEService: remove internal publishers; switch to delegate-only events

- Drop legacy messages/peers/fullPeers publishers
- Provide lightweight peerSnapshotSubject only to satisfy Transport
- Rework publishFullPeerData to build snapshots from internal state and notify delegate + subject
- Remove all peersPublisher.send call sites
- Keep UnifiedPeerService on delegate updates exclusively

* Remove inlined Nostr send helpers from ChatViewModel; route via MessageRouter

- Replace direct Nostr sends (PM, ACKs, favorites) with MessageRouter
- Add router method for delivery ACKs and implement NostrTransport.sendDeliveryAck
- Simplify ChatViewModel favorite notification path to use router
- Keep Nostr receive handling intact; reduce duplication

* Fix ReadReceipt initializer usage in ChatViewModel (readerID + readerNickname)

* Fix unused variable warning: replace shadowed 'nostrPubkey' bind with boolean check in ChatViewModel

* Fix queued PM format: use TLV for pending messages after Noise handshake

- Pending messages (including first-time favorite notifications) now use the same TLV encoding as normal sends
- Ensures ChatViewModel can decode on first send, even if handshake completes after queuing

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack 2025-08-17 15:17:04 +02:00 committed by GitHub
parent 3ebfa85e90
commit 6fbf7eee25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 873 additions and 1647 deletions

1
.gitignore vendored
View File

@ -7,6 +7,7 @@ plans/
## AI
CLAUDE.md
AGENTS.md
## User settings
xcuserdata/

View File

@ -1,469 +0,0 @@
# AI Context for BitChat
This document provides essential context for AI assistants working on the BitChat codebase. Read this first to understand the project's architecture, design decisions, and key concepts.
## Project Overview
BitChat is a decentralized, peer-to-peer messaging application that works over Bluetooth mesh networks without requiring internet connectivity, servers, or phone numbers. It's designed for scenarios where traditional communication infrastructure is unavailable or untrusted.
### Key Features
- **Bluetooth Mesh Networking**: Multi-hop message relay over BLE
- **Privacy-First Design**: No accounts, no persistent identifiers
- **End-to-End Encryption**: Uses Noise Protocol Framework for private messages
- **IRC-Style Commands**: Familiar `/msg`, `/who` interface
- **Cross-Platform**: Native iOS and macOS support
- **Nostr Integration**: Seamless fallback for mutual favorites when out of Bluetooth range
- **Hybrid Transport**: Automatic switching between Bluetooth and Nostr
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ User Interface │
│ (ContentView, ChatViewModel) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Application Services │
│ (MessageRetryService, DeliveryTracker, NotificationService) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Message Router │
│ (Transport selection, Favorites integration) │
└─────────────────────────────────────────────────────────────────┘
│ │
┌───────────────────────────────┐ ┌────────────────────────────────┐
│ Security Layer │ │ Nostr Protocol Layer │
│ (NoiseEncryptionService, │ │ (NostrProtocol, NIP-17, │
│ SecureIdentityStateManager) │ │ NostrRelayManager) │
└───────────────────────────────┘ └────────────────────────────────┘
│ │
┌───────────────────────────────┐ ┌────────────────────────────────┐
│ Protocol Layer │ │ Transport │
│ (BitchatProtocol, Binary- │ │ (WebSocket to Nostr │
│ Protocol, NoiseProtocol) │ │ relay servers) │
└───────────────────────────────┘ └────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Bluetooth Transport Layer │
│ (SimplifiedBluetoothService) │
└─────────────────────────────────────────────────────────────────┘
```
## Core Components
### 1. SimplifiedBluetoothService (Transport Layer)
- **Location**: `bitchat/Services/SimplifiedBluetoothService.swift`
- **Purpose**: Manages BLE connections and implements mesh networking
- **Key Responsibilities**:
- Peer discovery (scanning and advertising simultaneously)
- Connection management (acts as both central and peripheral)
- Message routing and relay
- Version negotiation with peers
- Automatic reconnection and topology management
### 2. BitchatProtocol (Protocol Layer)
- **Location**: `bitchat/Protocols/BitchatProtocol.swift`
- **Purpose**: Defines the application-level messaging protocol
- **Key Features**:
- Binary packet format for efficiency
- Message types: Chat, Announcement, PrivateMessage, etc.
- TTL-based routing (max 7 hops)
- Message deduplication via unique IDs
- Privacy features: padding, timing obfuscation
### 3. NoiseProtocol Implementation
- **Locations**:
- `bitchat/Noise/NoiseProtocol.swift` - Core protocol implementation
- `bitchat/Noise/NoiseSession.swift` - Session management
- `bitchat/Services/NoiseEncryptionService.swift` - High-level encryption API
- **Purpose**: Provides end-to-end encryption for private messages
- **Implementation Details**:
- Uses Noise_XX_25519_AESGCM_SHA256 pattern
- Mutual authentication via static keys
- Forward secrecy via ephemeral keys
- Integrated with identity management
### 4. Identity System
- **Location**: `bitchat/Identity/`
- **Three-Layer Model**:
1. **Ephemeral Identity**: Short-lived, rotates frequently
2. **Cryptographic Identity**: Long-term Noise static keypair
3. **Social Identity**: User-chosen nickname and metadata
- **Trust Levels**: Untrusted → Verified → Trusted → Blocked
### 5. ChatViewModel
- **Location**: `bitchat/ViewModels/ChatViewModel.swift`
- **Purpose**: Central state management and business logic
- **Responsibilities**:
- Message handling and batching
- Command processing (/msg, /who, etc.)
- UI state management
- Private chat coordination
### 6. Nostr Integration
- **Locations**:
- `bitchat/Nostr/NostrProtocol.swift` - NIP-17 private message implementation
- `bitchat/Nostr/NostrRelayManager.swift` - WebSocket relay connections
- `bitchat/Nostr/NostrIdentity.swift` - Nostr key management
- `bitchat/Services/MessageRouter.swift` - Transport selection logic
- **Purpose**: Enables communication with mutual favorites when out of Bluetooth range
- **Key Features**:
- NIP-17 gift-wrapped private messages for metadata privacy
- Automatic relay connection management
- Seamless transport switching between Bluetooth and Nostr
- Integrated with favorites system for mutual authentication
### 7. MessageRouter
- **Location**: `bitchat/Services/MessageRouter.swift`
- **Purpose**: Intelligent routing between Bluetooth mesh and Nostr transports
- **Transport Selection Logic**:
1. Always prefer Bluetooth mesh when peer is connected
2. Use Nostr for mutual favorites when peer is offline
3. Fail gracefully when no transport is available
- **Message Types Routed**:
- Regular chat messages
- Favorite/unfavorite notifications
- Delivery acknowledgments
- Read receipts
## Key Design Decisions
### 1. Protocol Design
- **Binary Protocol**: Chosen for efficiency over BLE's limited bandwidth
- **No JSON**: Reduces parsing overhead and message size
- **Custom Framing**: Handles BLE's 512-byte MTU limitations
### 2. Security Architecture
- **Noise Protocol**: Industry-standard, well-analyzed framework
- **XX Pattern**: Provides mutual authentication and forward secrecy
- **No Long-Term Identifiers**: Enhances privacy and deniability
### 3. Mesh Networking
- **TTL-Based Routing**: Prevents infinite loops in mesh
- **Bloom Filters**: Efficient duplicate detection
### 4. Privacy Features
- **Message Padding**: Obscures message length
- **Timing Obfuscation**: Randomized delays
- **Emergency Wipe**: Triple-tap to clear all data
### 5. Nostr Integration
- **NIP-17 Gift Wraps**: Maximum metadata privacy
- **Ephemeral Keys**: Each message uses unique ephemeral keys
- **Mutual Favorites Only**: Requires bidirectional trust
- **Transport Abstraction**: Users don't need to know about Nostr
## Code Organization
### Services (`/bitchat/Services/`)
Application-level services that coordinate between layers:
- `SimplifiedBluetoothService`: Core networking
- `NoiseEncryptionService`: Encryption coordination
- `MessageRetryService`: Reliability layer
- `DeliveryTracker`: Acknowledgment handling
- `NotificationService`: System notifications
### Protocols (`/bitchat/Protocols/`)
Protocol definitions and implementations:
- `BitchatProtocol`: Application protocol
- `BinaryProtocol`: Low-level encoding
- `BinaryEncodingUtils`: Helper functions
### Noise (`/bitchat/Noise/`)
Noise Protocol Framework implementation:
- `NoiseProtocol`: Core cryptographic operations
- `NoiseSession`: Session state management
- `NoiseHandshakeCoordinator`: Handshake orchestration
- `NoiseSecurityConsiderations`: Security validations
### Views & ViewModels
MVVM architecture for UI:
- `ContentView`: Main chat interface
- `ChatViewModel`: Business logic and state
- Supporting views for settings, identity, etc.
## Nostr Protocol Implementation
### Overview
BitChat integrates Nostr as a secondary transport for communicating with mutual favorites when Bluetooth connectivity is unavailable. This integration is transparent to users - messages automatically route through Nostr when needed.
### NIP-17 Private Direct Messages
BitChat implements NIP-17 (Private Direct Messages) for metadata-private communication:
1. **Gift Wrap Structure**:
```
Gift Wrap (kind 1059) → Seal (kind 13) → Rumor (kind 1)
```
- **Rumor**: The actual message content (unsigned)
- **Seal**: Encrypted rumor, hides sender identity
- **Gift Wrap**: Double-encrypted, tagged for recipient
2. **Ephemeral Keys**:
- Each message uses TWO ephemeral key pairs
- Seal uses one ephemeral key
- Gift wrap uses a different ephemeral key
- Provides sender anonymity and forward secrecy
3. **Timestamp Randomization**:
- ±1 minute randomization (reduced from NIP-17's ±15 minutes)
- Prevents timing correlation attacks
- Configurable in `NostrProtocol.randomizedTimestamp()`
### Favorites Integration
The Nostr transport is only available for mutual favorites:
1. **Favorite Establishment**:
- User favorites a peer via `/fav` command
- Favorite notification sent via Bluetooth (if connected)
- Peer's Nostr public key exchanged during favorite process
- Stored in `FavoritesPersistenceService`
2. **Mutual Requirement**:
- Both peers must favorite each other
- Prevents spam and unwanted Nostr messages
- Enforced by `MessageRouter` transport selection
3. **Nostr Key Management**:
- Derived from Noise static key using BIP-32
- Path: `m/44'/1237'/0'/0/0` (1237 = "NOSTR" in decimal)
- Consistent npub across app reinstalls
- Keys never leave the device
### Message Routing Logic
`MessageRouter` automatically selects transport:
```swift
if peerAvailableOnMesh {
transport = .bluetoothMesh // Always prefer mesh
} else if isMutualFavorite {
transport = .nostr // Use Nostr for offline favorites
} else {
throw MessageRouterError.peerNotReachable
}
```
### Relay Configuration
Default relays (hardcoded for reliability):
- `wss://relay.damus.io`
- `wss://relay.primal.net`
- `wss://offchain.pub`
- `wss://nostr21.com`
Relay selection criteria:
- Geographic distribution
- High uptime
- No authentication required
- Support for ephemeral events
### Message Format
Structured content for different message types:
- Chat: `MSG:<messageID>:<content>`
- Favorite: `FAVORITED:<senderNpub>` or `UNFAVORITED:<senderNpub>`
- Delivery ACK: `DELIVERED:<messageID>`
- Read Receipt: `READ:<base64EncodedReceipt>`
### Implementation Details
1. **NostrRelayManager**:
- Manages WebSocket connections to relays
- Handles reconnection logic
- Processes EVENT, EOSE, OK, NOTICE messages
- Implements NIP-01 relay protocol
2. **NostrProtocol**:
- Implements NIP-17 encryption/decryption
- Handles gift wrap creation/unwrapping
- Manages ephemeral key generation
- Provides Schnorr signatures
3. **ProcessedMessagesService**:
- Prevents duplicate message processing
- Tracks last subscription timestamp
- Persists across app launches
- 30-day retention window
### Security Considerations
1. **Metadata Protection**:
- Sender identity hidden via ephemeral keys
- Recipient only visible in gift wrap p-tag
- Timing correlation prevented via randomization
- Message content double-encrypted
2. **Relay Trust**:
- Relays cannot read message content
- Relays can see recipient pubkey (gift wrap)
- Relays cannot determine sender
- Multiple relays used for redundancy
3. **Key Hygiene**:
- Ephemeral keys used once and discarded
- Static Nostr key derived from Noise key
- No key reuse between messages
- Keys cleared from memory after use
### Debugging Nostr Issues
1. **Check relay connections**:
- Look for "Connected to Nostr relay" in logs
- Verify WebSocket state in NostrRelayManager
- Check for relay errors/notices
2. **Verify gift wrap creation**:
- Enable debug logging in NostrProtocol
- Check ephemeral key generation
- Verify encryption steps
3. **Message delivery**:
- Check ProcessedMessagesService for duplicates
- Verify subscription filters
- Look for EVENT messages in relay responses
## Development Guidelines
### 1. Security First
- Never log sensitive data (keys, message content)
- Use `SecureLogger` for security-aware logging
- Validate all inputs from network
- Follow principle of least privilege
### 2. Performance Considerations
- BLE has limited bandwidth (~20KB/s practical)
- Minimize protocol overhead
- Batch operations where possible
- Use compression for large messages
### 3. Testing
- Unit tests for protocol logic
- Integration tests for service interactions
- End-to-end tests for user flows
- Mock objects for BLE testing
### 4. Error Handling
- Graceful degradation for network issues
- Clear error messages for users
- Automatic retry with backoff
- Never expose internal errors
## Common Tasks
### Adding a New Message Type
1. Define in `MessageType` enum in `BitchatProtocol.swift`
2. Implement encoding/decoding logic
3. Add handling in `ChatViewModel`
4. Update UI if needed
5. Add tests
### Implementing a New Command
1. Add to `ChatViewModel.processCommand()`
2. Define any new message types needed
3. Implement command logic
4. Add autocomplete support
5. Update help text
### Debugging Bluetooth Issues
1. Check `SimplifiedBluetoothService` logs
2. Verify peer states and connections
3. Monitor characteristic updates
4. Use Bluetooth debugging tools
### Working with Nostr Transport
1. Verify mutual favorite status in `FavoritesPersistenceService`
2. Check Nostr key derivation in `NostrIdentity`
3. Monitor relay connections in `NostrRelayManager`
4. Test gift wrap encryption/decryption
5. Verify transport selection in `MessageRouter`
### Adding Nostr Features
1. Understand NIP-17 gift wrap structure
2. Maintain ephemeral key hygiene
3. Test with multiple relays
4. Preserve metadata privacy
5. Handle relay disconnections gracefully
## Security Threat Model
### Assumptions
- Adversaries can intercept all Bluetooth traffic
- Devices may be compromised
- No trusted infrastructure available
### Protections
- End-to-end encryption for private messages
- Message authentication via HMAC
- Forward secrecy via ephemeral keys
- Deniability through lack of signatures
### Limitations
- Public messages are unencrypted by design
- Metadata (who talks to whom) partially visible
- Timing attacks possible on mesh network
- No protection against flooding/spam (yet)
## Performance Optimizations
### Implemented
- LZ4 compression for messages
- Adaptive duty cycling for battery
- Connection caching and reuse
- Bloom filters for deduplication
### Future Improvements
- Protocol buffer encoding
- Better mesh routing algorithms
- Predictive pre-connection
- Smarter retransmission
## Troubleshooting Guide
### Common Issues
1. **Peers not discovering**: Check Bluetooth permissions, ensure app is in foreground
2. **Messages not delivering**: Verify mesh connectivity, check TTL values
3. **Handshake failures**: Ensure identity state is consistent, check key storage
4. **Performance issues**: Monitor connection count, check for message loops
## External Dependencies
### Swift Packages
- CryptoKit: Apple's crypto framework
- Network.framework: For future internet support
- No third-party dependencies (by design)
### System Requirements
- iOS 14.0+ / macOS 11.0+
- Bluetooth LE hardware
- ~50MB storage for app + data
## Future Roadmap
### Planned Features
- Internet bridging for hybrid networks
- Group chat with forward secrecy
- Voice messages with Opus codec
- File transfer support
### Architecture Evolution
- Plugin system for transports
- Modular protocol stack
- Cross-platform core library
- Federation between networks
---
## Quick Start for AI Assistants
1. **Understand the layers**: Transport → Protocol → Security → Services → UI
2. **Follow the data flow**: BLE/Nostr → Binary/JSON → Protocol → ViewModel → View
3. **Respect security boundaries**: Never mix trusted and untrusted data
4. **Test thoroughly**: This is critical infrastructure for users
5. **Ask about design decisions**: Many choices have non-obvious reasons
6. **Dual Transport**: Remember that messages can flow over Bluetooth OR Nostr
7. **Favorites System**: Nostr only works between mutual favorites
When in doubt, prioritize security and privacy over features. BitChat users depend on this app in situations where traditional communication has failed them.

204
CLAUDE.md
View File

@ -1,204 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
BitChat is a decentralized peer-to-peer messaging iOS/macOS app that works over Bluetooth mesh networks without requiring internet, servers, or phone numbers. It implements end-to-end encryption using the Noise Protocol Framework and integrates Nostr as a fallback transport for mutual favorites.
**Core Value Proposition**: Side-groupchat for scenarios where traditional communication infrastructure is unavailable or untrusted. Security and reliability are paramount over features.
## Common Development Commands
### Building the Project
```bash
# Option 1: Generate Xcode project with XcodeGen (preferred)
xcodegen generate
open bitchat.xcodeproj
# Option 2: Open with Swift Package Manager
open Package.swift
# Option 3: Quick macOS build and run using Just
just run # Build and run macOS app
just build # Build only
just clean # Clean and restore original files
just dev-run # Quick development build
```
### Testing
```bash
# Run iOS tests
xcodebuild test -project bitchat.xcodeproj -scheme "bitchat (iOS)" -destination "platform=iOS Simulator,name=iPhone 15"
# Run macOS tests
xcodebuild test -project bitchat.xcodeproj -scheme "bitchat (macOS)" -destination "platform=macOS"
# Run specific test file
xcodebuild test -project bitchat.xcodeproj -scheme "bitchat (iOS)" -only-testing:bitchatTests_iOS/NoiseProtocolTests
# Run all tests with verbose output
xcodebuild test -project bitchat.xcodeproj -scheme "bitchat (iOS)" -destination "platform=iOS Simulator,name=iPhone 15" -verbose
```
### Building for Device
```bash
# Build for iOS device
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -configuration Release -destination "generic/platform=iOS" archive
# Build for macOS (unsigned)
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
```
## High-Level Architecture
BitChat uses a layered architecture with clear separation of concerns:
### Core Architecture Layers
1. **Transport Layer** (`SimplifiedBluetoothService`, `NostrRelayManager`)
- Handles Bluetooth LE mesh networking and Nostr WebSocket connections
- Manages peer discovery, connection lifecycle, and message routing
- Implements automatic transport selection based on peer availability
2. **Protocol Layer** (`BitchatProtocol`, `NostrProtocol`)
- Defines binary message format for efficient BLE transmission
- Implements NIP-17 gift-wrapped messages for Nostr privacy
- Handles message framing, fragmentation, and reassembly
3. **Security Layer** (`NoiseProtocol`, `NoiseEncryptionService`)
- Implements Noise_XX_25519_AESGCM_SHA256 for E2E encryption
- Manages cryptographic handshakes and session keys
- Provides forward secrecy and mutual authentication
4. **Application Services** (`FavoritesPersistenceService`, `NotificationService`, `PeerStateManager`)
- Manages favorites system and mutual trust relationships
- Handles system notifications and peer state
- Transport selection logic integrated in ChatViewModel (lines 653-665)
5. **UI Layer** (`ChatViewModel`, `ContentView`)
- MVVM architecture with SwiftUI views
- Manages chat state, commands, and user interactions
- Handles private chats and channel management
### Key Design Patterns
- **Transport Abstraction**: Messages automatically route through available transports (Bluetooth when connected, Nostr for mutual favorites when offline)
- **Three-Layer Identity**: Ephemeral, Cryptographic, and Social identities
- **Mutual Favorites**: Nostr transport requires bidirectional trust and is fully functional
- **Binary Protocol**: Optimized for BLE's limited bandwidth (~20KB/s)
- **Efficient Message Deduplication**: Time-based LRU cache prevents loops in mesh network
- **TTL-Based Routing**: Maximum 7 hops to prevent infinite propagation
- **Consolidated Maintenance**: Single timer handles all periodic tasks (announces, cleanup, connectivity checks)
### Critical Files and Their Roles
- `Services/SimplifiedBluetoothService.swift` (~1860 lines): Core BLE mesh implementation
- `Services/FavoritesPersistenceService.swift`: Manages favorite relationships
- `Noise/NoiseProtocol.swift` (~900 lines): Cryptographic protocol implementation
- `Nostr/NostrProtocol.swift`: NIP-17 private message implementation
- `ViewModels/ChatViewModel.swift` (~3100 lines): Central business logic and state
- `Protocols/BitchatProtocol.swift` (~1100 lines): Binary message format definitions
- `Protocols/BinaryProtocol.swift` (~580 lines): Low-level encoding/decoding
## Important Considerations
### Security
- Never log sensitive data (keys, message content)
- Use `SecureLogger` for security-aware logging
- All private messages use end-to-end encryption
- Identity keys stored in iOS Keychain
### Performance
- Bluetooth LE has ~512 byte MTU, messages are fragmented
- Use LZ4 compression for large messages
- Minimize protocol overhead for battery efficiency
- Batch UI updates to prevent performance issues
### Testing Requirements
- Physical devices required (Bluetooth doesn't work in simulator)
- Test with multiple devices for mesh functionality
- Enable Bluetooth permissions in device settings
- Test both transport modes (Bluetooth and Nostr)
### Platform Differences
- iOS: Full background Bluetooth support with proper entitlements
- macOS: Limited background support, may require app in foreground
- Share Extension: iOS only, for sharing content to BitChat
### Known Quirks
- XcodeGen may require temporary file moves for macOS builds (handled by Justfile)
- LaunchScreen.storyboard is iOS-only, needs hiding for macOS builds
- Nostr keys derived from Noise keys using BIP-32 path m/44'/1237'/0'/0/0
## Debugging Tips
### Bluetooth Issues
- Check `SimplifiedBluetoothService` state and peer connections
- Verify app has Bluetooth permissions enabled
- Ensure devices are in range (<50 meters typical)
- Monitor characteristic notifications and MTU
### Nostr Transport (Fully Functional)
- Verify mutual favorite status in `FavoritesPersistenceService`
- Check relay connections in `NostrRelayManager` logs
- Test gift wrap encryption/decryption in `NostrProtocol`
- Transport selection happens automatically in `ChatViewModel.sendPrivateMessage()` (lines 653-665)
### Message Delivery
- Check for message processing in `SimplifiedBluetoothService.handleReceivedPacket()`
- Verify handshake state for private messages in `NoiseEncryptionService`
- Efficient deduplication uses `MessageDeduplicator` class with time-based cleanup
- Monitor TTL values in mesh propagation (max 7 hops)
## Common Tasks
### Adding New Message Types
1. Define in `MessageType` enum in `BitchatProtocol.swift`
2. Implement encoding/decoding in `BinaryProtocol.swift`
3. Add handling in `ChatViewModel.processMessage()`
4. Update UI components if needed
5. Add unit tests for encoding/decoding in `bitchatTests/Protocol/`
### Implementing New Commands
1. Add to `ChatViewModel.processCommand()` (~line 2800)
2. Define required message types if needed
3. Implement command logic and validation
4. Add to autocomplete suggestions in `getCommandSuggestions()`
5. Update help text in `/help` command implementation
### Modifying Transports
1. Update transport-specific protocol implementations
2. Test failover between transports (automatic in ChatViewModel)
3. Verify message delivery in both modes
4. Nostr integration is complete and functional for mutual favorites
### Running Code Quality Checks
```bash
# SwiftLint (if configured)
swiftlint lint --path bitchat/
# Swift format check (if using swift-format)
swift-format lint -r bitchat/
# Build with strict warnings
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -configuration Debug SWIFT_TREAT_WARNINGS_AS_ERRORS=YES build
```
## Recent Optimizations
### Performance Improvements
- **Message Deduplication**: Replaced O(n) Set recreation with efficient time-based LRU cache
- **Timer Consolidation**: Single maintenance timer replaces multiple timers, reducing overhead
- **Main Thread Optimization**: Added `notifyUI()` helper to avoid unnecessary dispatches
- **didReceiveMessage Refactoring**: Split 300-line method into focused helper methods
### Architecture Clarifications
- Nostr transport is fully implemented and functional (not partial)
- Transport selection logic is integrated in ChatViewModel, not a separate service
- Peer state management could be further consolidated (3 systems track same data)
Remember: This app is designed for scenarios where traditional communication infrastructure is unavailable or untrusted. Security and reliability are paramount over features.

View File

@ -15,6 +15,22 @@
049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
049BD3992E506A12001A566B /* UnifiedPeerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3982E506A12001A566B /* UnifiedPeerService.swift */; };
049BD39A2E506A12001A566B /* UnifiedPeerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3982E506A12001A566B /* UnifiedPeerService.swift */; };
049BD39C2E51DBD9001A566B /* NostrEmbeddedBitChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */; };
049BD39D2E51DBD9001A566B /* NostrEmbeddedBitChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */; };
049BD3A02E51DBF4001A566B /* Packets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD39E2E51DBF4001A566B /* Packets.swift */; };
049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD39F2E51DBF4001A566B /* PeerID.swift */; };
049BD3A22E51DBF4001A566B /* Packets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD39E2E51DBF4001A566B /* Packets.swift */; };
049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD39F2E51DBF4001A566B /* PeerID.swift */; };
049BD3A52E51DC0E001A566B /* MessageDeduplicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */; };
049BD3A62E51DC0E001A566B /* MessageDeduplicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */; };
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */; };
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */; };
049BD3AE2E51ED60001A566B /* Transport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3AD2E51ED60001A566B /* Transport.swift */; };
049BD3AF2E51ED60001A566B /* Transport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3AD2E51ED60001A566B /* Transport.swift */; };
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B12E51F319001A566B /* NostrTransport.swift */; };
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B12E51F319001A566B /* NostrTransport.swift */; };
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
@ -24,7 +40,7 @@
2EFCCAA297B16FA2B56747C7 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC75901A0F0073B5BB8356E7 /* TestConstants.swift */; };
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */; };
37DDF3D09E2BAB92A5A8A9C1 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */; };
3849CA6D99B2D536636DF4A6 /* MockSimplifiedBluetoothService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7CCF2BD78A3F3DAE6DA145 /* MockSimplifiedBluetoothService.swift */; };
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */; };
38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */; };
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
@ -36,7 +52,7 @@
68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */; };
6A85FC357ACD85DBD9020845 /* NostrRelayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78595178957244CBDF7E79B6 /* NostrRelayManager.swift */; };
6C63FA98D59854C15C57B3D6 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */; };
6C803BF930E7E19BE6E99EAA /* MockSimplifiedBluetoothService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7CCF2BD78A3F3DAE6DA145 /* MockSimplifiedBluetoothService.swift */; };
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */; };
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */; };
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; };
6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
@ -55,7 +71,7 @@
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; };
8CE446C9364F54DF89E7A364 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; };
8D0196EAEE56973679F6A655 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC75901A0F0073B5BB8356E7 /* TestConstants.swift */; };
8DE687D2EB5EB120868DBFB5 /* SimplifiedBluetoothService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6FDA03416FDB2157A0A8C7 /* SimplifiedBluetoothService.swift */; };
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6FDA03416FDB2157A0A8C7 /* BLEService.swift */; };
8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC5AB43F4A8FB62C935CD74 /* IntegrationTests.swift */; };
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; };
923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; };
@ -66,7 +82,7 @@
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B378C16594575FCC7F9C75 /* ShareViewController.swift */; };
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; };
A2977428C1D9EF9944C4BFAF /* SimplifiedBluetoothServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* SimplifiedBluetoothServiceTests.swift */; };
A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; };
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; };
AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
@ -80,8 +96,8 @@
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */; };
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
BCD0EBACD82AF5E55C2CB2B9 /* NostrRelayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78595178957244CBDF7E79B6 /* NostrRelayManager.swift */; };
BE729E149C98F775D9622D9C /* SimplifiedBluetoothServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* SimplifiedBluetoothServiceTests.swift */; };
C165DD35BB8E9C327A3C2DA4 /* SimplifiedBluetoothService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6FDA03416FDB2157A0A8C7 /* SimplifiedBluetoothService.swift */; };
BE729E149C98F775D9622D9C /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; };
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6FDA03416FDB2157A0A8C7 /* BLEService.swift */; };
C3B1226CD30C87501EF6F12F /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */; };
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; };
D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
@ -144,6 +160,14 @@
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>"; };
049BD3982E506A12001A566B /* UnifiedPeerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedPeerService.swift; sourceTree = "<group>"; };
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEmbeddedBitChat.swift; sourceTree = "<group>"; };
049BD39E2E51DBF4001A566B /* Packets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Packets.swift; sourceTree = "<group>"; };
049BD39F2E51DBF4001A566B /* PeerID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerID.swift; sourceTree = "<group>"; };
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = "<group>"; };
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = "<group>"; };
049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = "<group>"; };
049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = "<group>"; };
049BD3B12E51F319001A566B /* NostrTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrTransport.swift; sourceTree = "<group>"; };
05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; };
0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; };
@ -168,13 +192,13 @@
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoView.swift; sourceTree = "<group>"; };
78595178957244CBDF7E79B6 /* NostrRelayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrRelayManager.swift; sourceTree = "<group>"; };
8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatE2ETests.swift; sourceTree = "<group>"; };
8C6FDA03416FDB2157A0A8C7 /* SimplifiedBluetoothService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimplifiedBluetoothService.swift; sourceTree = "<group>"; };
8C6FDA03416FDB2157A0A8C7 /* BLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEService.swift; sourceTree = "<group>"; };
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputValidator.swift; sourceTree = "<group>"; };
9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FingerprintView.swift; sourceTree = "<group>"; };
95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
980B109CBA72BC996455C62B /* SimplifiedBluetoothServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimplifiedBluetoothServiceTests.swift; sourceTree = "<group>"; };
980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = "<group>"; };
9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; };
9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.swift; sourceTree = "<group>"; };
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@ -193,7 +217,7 @@
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatApp.swift; sourceTree = "<group>"; };
FC75901A0F0073B5BB8356E7 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = "<group>"; };
FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; };
FE7CCF2BD78A3F3DAE6DA145 /* MockSimplifiedBluetoothService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSimplifiedBluetoothService.swift; sourceTree = "<group>"; };
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; };
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -305,7 +329,7 @@
isa = PBXGroup;
children = (
C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */,
FE7CCF2BD78A3F3DAE6DA145 /* MockSimplifiedBluetoothService.swift */,
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */,
);
path = Mocks;
sourceTree = "<group>";
@ -313,6 +337,8 @@
9A78348821A7D3374607D4E3 /* Utils */ = {
isa = PBXGroup;
children = (
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */,
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */,
32F149C43D1915831B60FE09 /* CompressionUtil.swift */,
90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */,
EE7EFB209C86BBD956B749EC /* SecureLogger.swift */,
@ -356,6 +382,8 @@
ADD53BCDA233C02E53458926 /* Protocols */ = {
isa = PBXGroup;
children = (
049BD39E2E51DBF4001A566B /* Packets.swift */,
049BD39F2E51DBF4001A566B /* PeerID.swift */,
5318B743C64628A125261163 /* BinaryEncodingUtils.swift */,
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */,
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */,
@ -377,7 +405,7 @@
children = (
D69A18D27F9A565FD6041E12 /* Info.plist */,
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
980B109CBA72BC996455C62B /* SimplifiedBluetoothServiceTests.swift */,
980B109CBA72BC996455C62B /* BLEServiceTests.swift */,
C2F78AB254FDAD5FEDA18B58 /* EndToEnd */,
5B90895AFF0957E08FA3D429 /* Integration */,
966CD21F221332CF564AC724 /* Mocks */,
@ -408,6 +436,9 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup;
children = (
049BD3B02E51F319001A566B /* MessageRouter.swift */,
049BD3B12E51F319001A566B /* NostrTransport.swift */,
049BD3AD2E51ED60001A566B /* Transport.swift */,
049BD3982E506A12001A566B /* UnifiedPeerService.swift */,
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */,
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */,
@ -416,7 +447,7 @@
136696FC4436A02D98CE6A77 /* KeychainManager.swift */,
394E8A1AC76EFAE352075BE9 /* NoiseEncryptionService.swift */,
3448F84BF86A42A3CC4A9379 /* NotificationService.swift */,
8C6FDA03416FDB2157A0A8C7 /* SimplifiedBluetoothService.swift */,
8C6FDA03416FDB2157A0A8C7 /* BLEService.swift */,
);
path = Services;
sourceTree = "<group>";
@ -424,6 +455,7 @@
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
isa = PBXGroup;
children = (
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
78595178957244CBDF7E79B6 /* NostrRelayManager.swift */,
@ -620,6 +652,8 @@
files = (
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */,
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */,
84E3F9B64FB7FB4A140BD0A8 /* BitchatPeer.swift in Sources */,
@ -636,19 +670,25 @@
501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */,
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */,
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */,
8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */,
049BD39D2E51DBD9001A566B /* NostrEmbeddedBitChat.swift in Sources */,
049BD3992E506A12001A566B /* UnifiedPeerService.swift in Sources */,
C3B1226CD30C87501EF6F12F /* NostrIdentity.swift in Sources */,
FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */,
049BD3A62E51DC0E001A566B /* MessageDeduplicator.swift in Sources */,
6A85FC357ACD85DBD9020845 /* NostrRelayManager.swift in Sources */,
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
049BD3AF2E51ED60001A566B /* Transport.swift in Sources */,
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */,
049BD3A02E51DBF4001A566B /* Packets.swift in Sources */,
049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */,
049BD3942E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
049BD3952E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
8DE687D2EB5EB120868DBFB5 /* SimplifiedBluetoothService.swift in Sources */,
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -658,6 +698,8 @@
files = (
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */,
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */,
84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */,
@ -674,19 +716,25 @@
5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */,
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
049BD39C2E51DBD9001A566B /* NostrEmbeddedBitChat.swift in Sources */,
049BD39A2E506A12001A566B /* UnifiedPeerService.swift in Sources */,
D782AB596DDB5C846554F7C3 /* NostrIdentity.swift in Sources */,
F06732B1719EE13C5D09CE77 /* NostrProtocol.swift in Sources */,
049BD3A52E51DC0E001A566B /* MessageDeduplicator.swift in Sources */,
BCD0EBACD82AF5E55C2CB2B9 /* NostrRelayManager.swift in Sources */,
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */,
049BD3AE2E51ED60001A566B /* Transport.swift in Sources */,
68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */,
049BD3A22E51DBF4001A566B /* Packets.swift in Sources */,
049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */,
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
C165DD35BB8E9C327A3C2DA4 /* SimplifiedBluetoothService.swift in Sources */,
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -697,12 +745,12 @@
AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */,
8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */,
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
6C803BF930E7E19BE6E99EAA /* MockSimplifiedBluetoothService.swift in Sources */,
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
ED83C7AC1E6BEF15389C0132 /* PrivateChatE2ETests.swift in Sources */,
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */,
A2977428C1D9EF9944C4BFAF /* SimplifiedBluetoothServiceTests.swift in Sources */,
A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */,
2EFCCAA297B16FA2B56747C7 /* TestConstants.swift in Sources */,
B45AD5BF95220A0289216D32 /* TestHelpers.swift in Sources */,
);
@ -715,12 +763,12 @@
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */,
686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */,
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
3849CA6D99B2D536636DF4A6 /* MockSimplifiedBluetoothService.swift in Sources */,
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */,
8CE446C9364F54DF89E7A364 /* PublicChatE2ETests.swift in Sources */,
BE729E149C98F775D9622D9C /* SimplifiedBluetoothServiceTests.swift in Sources */,
BE729E149C98F775D9622D9C /* BLEServiceTests.swift in Sources */,
8D0196EAEE56973679F6A655 /* TestConstants.swift in Sources */,
37DDF3D09E2BAB92A5A8A9C1 /* TestHelpers.swift in Sources */,
);

View File

@ -98,10 +98,10 @@ class PeerManager: ObservableObject {
@Published var favorites: [BitchatPeer] = []
@Published var mutualFavorites: [BitchatPeer] = []
private let meshService: SimplifiedBluetoothService
private let meshService: Transport
private let favoritesService = FavoritesPersistenceService.shared
init(meshService: SimplifiedBluetoothService) {
init(meshService: Transport) {
self.meshService = meshService
updatePeers()

View File

@ -0,0 +1,78 @@
import Foundation
// MARK: - BitChat-over-Nostr Adapter
struct NostrEmbeddedBitChat {
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
// TLV-encode the private message
let pm = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlv = pm.encode() else { return nil }
// Prefix with NoisePayloadType
var payload = Data([NoisePayloadType.privateMessage.rawValue])
payload.append(tlv)
// Determine 8-byte recipient ID to embed
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(),
recipientID: Data(hexString: recipientIDHex),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 7
)
guard let data = packet.toBinaryData() else { return nil }
return "bitchat1:" + base64URLEncode(data)
}
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
guard type == .delivered || type == .readReceipt else { return nil }
var payload = Data([type.rawValue])
payload.append(Data(messageID.utf8))
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(),
recipientID: Data(hexString: recipientIDHex),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 7
)
guard let data = packet.toBinaryData() else { return nil }
return "bitchat1:" + base64URLEncode(data)
}
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
if let maybeData = Data(hexString: recipientPeerID) {
if maybeData.count == 32 {
// Treat as Noise static public key; derive peerID from fingerprint
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
} else if maybeData.count == 8 {
// Already an 8-byte peer ID
return recipientPeerID
}
}
// Fallback: return as-is (expecting 16 hex chars) caller should pass a valid peer ID
return recipientPeerID
}
/// Base64url encode without padding
private static func base64URLEncode(_ data: Data) -> String {
let b64 = data.base64EncodedString()
return b64
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}

View File

@ -32,7 +32,7 @@
/// 1. **Creation**: Messages are created with type, content, and metadata
/// 2. **Encoding**: Converted to binary format with proper field ordering
/// 3. **Fragmentation**: Split if larger than BLE MTU (512 bytes)
/// 4. **Transmission**: Sent via SimplifiedBluetoothService
/// 4. **Transmission**: Sent via BLEService
/// 5. **Routing**: Relayed by intermediate nodes (TTL decrements)
/// 6. **Reassembly**: Fragments collected and reassembled
/// 7. **Decoding**: Binary data parsed back to message objects
@ -461,6 +461,10 @@ protocol BitchatDelegate: AnyObject {
func isFavorite(fingerprint: String) -> Bool
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
// Low-level events for better separation of concerns
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
}
// Provide default implementation to make it effectively optional
@ -472,6 +476,14 @@ extension BitchatDelegate {
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
// Default empty implementation
}
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
// Default empty implementation
}
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
// Default empty implementation
}
}
// MARK: - Noise Payload Helpers

View File

@ -0,0 +1,116 @@
import Foundation
// MARK: - Protocol TLV Packets
struct AnnouncementPacket {
let nickname: String
let publicKey: Data
private enum TLVType: UInt8 {
case nickname = 0x01
case noisePublicKey = 0x02
}
func encode() -> Data? {
var data = Data()
// TLV for nickname
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
data.append(TLVType.nickname.rawValue)
data.append(UInt8(nicknameData.count))
data.append(nicknameData)
// TLV for public key
guard publicKey.count <= 255 else { return nil }
data.append(TLVType.noisePublicKey.rawValue)
data.append(UInt8(publicKey.count))
data.append(publicKey)
return data
}
static func decode(from data: Data) -> AnnouncementPacket? {
var offset = 0
var nickname: String?
var publicKey: Data?
while offset + 2 <= data.count {
guard let type = TLVType(rawValue: data[offset]) else { return nil }
offset += 1
let length = Int(data[offset])
offset += 1
guard offset + length <= data.count else { return nil }
let value = data[offset..<offset + length]
offset += length
switch type {
case .nickname:
nickname = String(data: value, encoding: .utf8)
case .noisePublicKey:
publicKey = Data(value)
}
}
guard let nickname = nickname, let publicKey = publicKey else { return nil }
return AnnouncementPacket(nickname: nickname, publicKey: publicKey)
}
}
struct PrivateMessagePacket {
let messageID: String
let content: String
private enum TLVType: UInt8 {
case messageID = 0x00
case content = 0x01
}
func encode() -> Data? {
var data = Data()
// TLV for messageID
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }
data.append(TLVType.messageID.rawValue)
data.append(UInt8(messageIDData.count))
data.append(messageIDData)
// TLV for content
guard let contentData = content.data(using: .utf8), contentData.count <= 255 else { return nil }
data.append(TLVType.content.rawValue)
data.append(UInt8(contentData.count))
data.append(contentData)
return data
}
static func decode(from data: Data) -> PrivateMessagePacket? {
var offset = 0
var messageID: String?
var content: String?
while offset + 2 <= data.count {
guard let type = TLVType(rawValue: data[offset]) else { return nil }
offset += 1
let length = Int(data[offset])
offset += 1
guard offset + length <= data.count else { return nil }
let value = data[offset..<offset + length]
offset += length
switch type {
case .messageID:
messageID = String(data: value, encoding: .utf8)
case .content:
content = String(data: value, encoding: .utf8)
}
}
guard let messageID = messageID, let content = content else { return nil }
return PrivateMessagePacket(messageID: messageID, content: content)
}
}

View File

@ -0,0 +1,14 @@
import Foundation
import CryptoKit
// MARK: - Peer ID Utilities
struct PeerIDUtils {
/// Derive the stable 16-hex peer ID from a Noise static public key
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
let digest = SHA256.hash(data: publicKey)
let hex = digest.map { String(format: "%02x", $0) }.joined()
return String(hex.prefix(16))
}
}

View File

@ -6,14 +6,19 @@ import CryptoKit
import UIKit
#endif
/// Simplified Bluetooth Mesh Service - Core functionality only
/// Target: <1500 lines (from 6470)
final class SimplifiedBluetoothService: NSObject {
/// BLEService Bluetooth Mesh Transport
/// - Emits events exclusively via `BitchatDelegate` for UI.
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
/// - A lightweight `peerSnapshotPublisher` is provided for non-UI services.
final class BLEService: NSObject {
// MARK: - Constants
#if DEBUG
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5A") // testnet
//static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
#else
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
#endif
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
private let maxFragmentSize = 469 // 512 MTU - headers
@ -97,44 +102,38 @@ final class SimplifiedBluetoothService: NSObject {
private weak var maintenanceTimer: Timer? // Single timer for all maintenance tasks
private var maintenanceCounter = 0 // Track maintenance cycles
// MARK: - Publishers
let messagesPublisher = PassthroughSubject<BitchatMessage, Never>()
let peersPublisher = PassthroughSubject<[String: String], Never>() // Legacy - for backward compatibility
// NEW: Full peer data publisher for UnifiedPeerService
struct PeerInfoSnapshot {
let id: String
let nickname: String
let isConnected: Bool
let noisePublicKey: Data?
let lastSeen: Date
// MARK: - Peer snapshots publisher (non-UI convenience)
private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
peerSnapshotSubject.eraseToAnyPublisher()
}
let fullPeersPublisher = CurrentValueSubject<[String: PeerInfoSnapshot], Never>([:])
// Helper to convert internal PeerInfo to public snapshot
private func createPeerSnapshot(_ info: PeerInfo) -> PeerInfoSnapshot {
PeerInfoSnapshot(
id: info.id,
nickname: info.nickname,
isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen
)
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
collectionsQueue.sync {
peers.values.map { info in
TransportPeerSnapshot(
id: info.id,
nickname: info.nickname,
isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen
)
}
}
}
// MARK: - Delegate
weak var delegate: BitchatDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?
// MARK: - Initialization
/// Notify UI on main thread (only if needed)
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
private func notifyUI(_ block: @escaping () -> Void) {
if Thread.isMainThread {
// Always hop onto the MainActor so calls to @MainActor delegates are safe
Task { @MainActor in
block()
} else {
DispatchQueue.main.async(execute: block)
}
}
@ -261,7 +260,7 @@ final class SimplifiedBluetoothService: NSObject {
// Start BLE services if not already running
if centralManager?.state == .poweredOn {
centralManager?.scanForPeripherals(
withServices: [SimplifiedBluetoothService.serviceUUID],
withServices: [BLEService.serviceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
@ -272,12 +271,18 @@ final class SimplifiedBluetoothService: NSObject {
self?.sendAnnounce(forceSend: true)
}
}
// Transport protocol conformance helper: simplified public message send
func sendMessage(_ content: String, mentions: [String]) {
// Delegate to the full API with default routing
sendMessage(content, mentions: mentions, to: nil, messageID: nil, timestamp: nil)
}
func stopServices() {
// Send leave message synchronously to ensure delivery
let leavePacket = BitchatPacket(
type: MessageType.leave.rawValue,
senderID: hexStringToData(myPeerID),
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(),
@ -322,9 +327,14 @@ final class SimplifiedBluetoothService: NSObject {
}
func isPeerConnected(_ peerID: String) -> Bool {
return collectionsQueue.sync {
return peers[peerID]?.isConnected ?? false
}
// 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 { peers[shortID]?.isConnected ?? false }
}
func getPeerNicknames() -> [String: String] {
@ -376,8 +386,8 @@ final class SimplifiedBluetoothService: NSObject {
let encrypted = try noiseService.encrypt(receiptPayload, for: peerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: hexStringToData(myPeerID),
recipientID: hexStringToData(peerID),
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted,
signature: nil,
@ -406,11 +416,14 @@ final class SimplifiedBluetoothService: NSObject {
func getPeerFingerprint(_ peerID: String) -> String? {
return collectionsQueue.sync {
if let publicKey = peers[peerID]?.noisePublicKey {
return dataToHexString(publicKey)
return publicKey.hexEncodedString()
}
return nil
}
}
// Transport compatibility: generic naming
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState {
if noiseService.hasEstablishedSession(with: peerID) {
@ -531,15 +544,15 @@ final class SimplifiedBluetoothService: NSObject {
}
}
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: hexStringToData(myPeerID),
recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted,
signature: nil,
ttl: messageTTL
)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted,
signature: nil,
ttl: messageTTL
)
// Call directly if already on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet)
@ -587,8 +600,8 @@ final class SimplifiedBluetoothService: NSObject {
// Send handshake init
let packet = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: hexStringToData(myPeerID),
recipientID: hexStringToData(peerID),
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: handshakeData,
signature: nil,
@ -622,39 +635,43 @@ final class SimplifiedBluetoothService: NSObject {
// Send each pending message directly (we know session is established)
for (content, messageID) in messages {
// Encrypt and send directly without checking session again
do {
// Create message payload with ID: [type byte] + [ID:xxxxx|content]
// Use the same TLV format as normal sends to keep receiver decoding consistent
let privateMessage = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlvData = privateMessage.encode() else {
SecureLogger.log("Failed to encode pending private message TLV", category: SecureLogger.noise, level: .error)
continue
}
var messagePayload = Data([NoisePayloadType.privateMessage.rawValue])
let messageWithID = "ID:\(messageID)|\(content)"
messagePayload.append(contentsOf: messageWithID.utf8)
messagePayload.append(tlvData)
let encrypted = try noiseService.encrypt(messagePayload, for: peerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: hexStringToData(myPeerID),
recipientID: hexStringToData(peerID),
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted,
signature: nil,
ttl: messageTTL
)
// We're already on messageQueue from the callback
broadcastPacket(packet)
// Notify delegate that message was sent
notifyUI { [weak self] in
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
}
SecureLogger.log("✅ Sent pending message \(messageID) to \(peerID) after handshake",
category: SecureLogger.session, level: .debug)
} catch {
SecureLogger.log("Failed to send pending message after handshake: \(error)",
category: SecureLogger.noise, level: .error)
// Notify delegate of failure
notifyUI { [weak self] in
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed"))
@ -691,7 +708,7 @@ final class SimplifiedBluetoothService: NSObject {
// Handshakes need broader delivery to establish encryption
if packet.type == MessageType.noiseEncrypted.rawValue,
let recipientID = packet.recipientID {
let recipientPeerID = dataToHexString(recipientID)
let recipientPeerID = recipientID.hexEncodedString()
var sentEncrypted = false
// Check routing availability (only log if there's an issue)
@ -756,7 +773,7 @@ final class SimplifiedBluetoothService: NSObject {
// 1. First try sending as central via writes to connected peripherals
// This is the preferred path when we have direct peripheral connections
for state in peripherals.values where state.isConnected {
if let characteristic = state.characteristic {
if let characteristic = state.characteristic {
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
sentToPeripherals += 1
}
@ -907,7 +924,7 @@ final class SimplifiedBluetoothService: NSObject {
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: String) {
// Deduplication (thread-safe)
let senderID = dataToHexString(packet.senderID)
let senderID = packet.senderID.hexEncodedString()
// Include packet type in message ID to prevent collisions between different packet types
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
@ -983,7 +1000,7 @@ final class SimplifiedBluetoothService: NSObject {
// Verify that the sender's derived ID from the announced public key matches the packet senderID
// This helps detect relayed or spoofed announces. Only warn in release; assert in debug.
let derivedFromKey = Self.derivePeerID(fromPublicKey: announcement.publicKey)
let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.publicKey)
if derivedFromKey != peerID {
SecureLogger.log("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))", category: SecureLogger.security, level: .warning)
#if DEBUG
@ -1062,8 +1079,7 @@ final class SimplifiedBluetoothService: NSObject {
self.delegate?.didConnectToPeer(peerID)
}
self.peersPublisher.send(self.getPeers())
self.publishFullPeerData() // NEW: Publish full peer data
self.publishFullPeerData()
self.delegate?.didUpdatePeerList(currentPeerIDs)
}
@ -1081,29 +1097,7 @@ final class SimplifiedBluetoothService: NSObject {
}
}
private func parseMentions(from content: String) -> [String] {
let pattern = "@([\\p{L}0-9_]+)"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
var mentions: [String] = []
// Get all known nicknames including our own
var allNicknames = Set(peers.values.map { $0.nickname })
allNicknames.insert(myNickname)
for match in matches {
if let range = Range(match.range(at: 1), in: content) {
let mentionedName = String(content[range])
// Check if this is a valid nickname
if allNicknames.contains(mentionedName) {
mentions.append(mentionedName)
}
}
}
return Array(Set(mentions)) // Remove duplicates
}
// Mention parsing moved to ChatViewModel
private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
// Don't process our own messages
@ -1119,43 +1113,24 @@ final class SimplifiedBluetoothService: NSObject {
let senderNickname = peers[peerID]?.nickname ?? "Unknown"
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug)
// Parse mentions from the message content
let mentions = parseMentions(from: content)
let message = BitchatMessage(
id: UUID().uuidString,
sender: senderNickname,
content: content,
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: mentions.isEmpty ? nil : mentions
)
// Send on main thread (without capturing self strongly)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in
guard let self = self else { return }
// Deliver to UI
self.messagesPublisher.send(message)
self.delegate?.didReceiveMessage(message)
self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
}
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: String) {
// Use NoiseEncryptionService for handshake processing
if let recipientID = packet.recipientID,
dataToHexString(recipientID) == myPeerID {
recipientID.hexEncodedString() == myPeerID {
// Handshake is for us
do {
if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) {
// Send response
let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: hexStringToData(myPeerID),
recipientID: hexStringToData(peerID),
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: response,
signature: nil,
@ -1186,7 +1161,7 @@ final class SimplifiedBluetoothService: NSObject {
return
}
let recipientHex = dataToHexString(recipientID)
let recipientHex = recipientID.hexEncodedString()
if recipientHex != myPeerID {
SecureLogger.log("🔐 Encrypted message not for me (for \(recipientHex), I am \(myPeerID))", category: SecureLogger.session, level: .debug)
return
@ -1205,71 +1180,20 @@ final class SimplifiedBluetoothService: NSObject {
switch NoisePayloadType(rawValue: payloadType) {
case .privateMessage:
// Try to decode as TLV first
guard let privateMessage = PrivateMessagePacket.decode(from: Data(payloadData)) else {
SecureLogger.log("⚠️ Failed to decode private message with TLV format",
category: SecureLogger.noise, level: .warning)
return
}
// Successfully decoded TLV format
let messageID = privateMessage.messageID
let messageContent = privateMessage.content
// Parse mentions even in private messages
let mentions = parseMentions(from: messageContent)
let message = BitchatMessage(
id: messageID,
sender: peers[peerID]?.nickname ?? "Unknown",
content: messageContent,
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: myNickname,
senderPeerID: peerID,
mentions: mentions.isEmpty ? nil : mentions
)
SecureLogger.log("🔓 Decrypted TLV PM from \(message.sender): \(messageContent.prefix(30))...", category: SecureLogger.session, level: .debug)
// Send on main thread
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in
if let delegate = self?.delegate {
SecureLogger.log("📨 Forwarding PM to ChatViewModel delegate", category: SecureLogger.session, level: .debug)
delegate.didReceiveMessage(message)
} else {
SecureLogger.log("⚠️ Delegate is nil, cannot forward PM to ChatViewModel", category: SecureLogger.session, level: .warning)
}
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts)
}
// Send delivery ACK
sendDeliveryAck(for: messageID, to: peerID)
case .delivered:
// Handle delivery ACK
guard let messageID = String(data: payloadData, encoding: .utf8) else { return }
// Delivery ACK received - no need to log
// Update delivery status
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: peerID, at: Date()))
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts)
}
case .readReceipt:
// Handle read receipt
guard let messageID = String(data: payloadData, encoding: .utf8) else { return }
SecureLogger.log("📖 Received READ receipt for message \(messageID) from \(peerID)",
category: SecureLogger.session, level: .debug)
// Update read status
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in
let nickname = self?.peers[peerID]?.nickname ?? "Unknown"
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .read(by: nickname, at: Date()))
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
}
default:
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
}
@ -1299,7 +1223,6 @@ final class SimplifiedBluetoothService: NSObject {
// Get current peer list (after removal)
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
self.peersPublisher.send(self.getPeers())
self.delegate?.didDisconnectFromPeer(peerID)
self.delegate?.didUpdatePeerList(currentPeerIDs)
}
@ -1361,7 +1284,7 @@ final class SimplifiedBluetoothService: NSObject {
}
}
private func sendDeliveryAck(for messageID: String, to peerID: String) {
func sendDeliveryAck(for messageID: String, to peerID: String) {
// Send encrypted delivery ACK
guard noiseService.hasSession(with: peerID) else {
SecureLogger.log("Cannot send ACK - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning)
@ -1376,8 +1299,8 @@ final class SimplifiedBluetoothService: NSObject {
let encrypted = try noiseService.encrypt(ackPayload, for: peerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: hexStringToData(myPeerID),
recipientID: hexStringToData(peerID),
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted,
signature: nil,
@ -1400,14 +1323,25 @@ final class SimplifiedBluetoothService: NSObject {
}
}
// NEW: Publish full peer data to subscribers
// NEW: Publish peer snapshots to subscribers and notify Transport delegates
private func publishFullPeerData() {
let snapshot = collectionsQueue.sync { () -> [String: PeerInfoSnapshot] in
Dictionary(uniqueKeysWithValues: peers.map { (id, info) in
(id, createPeerSnapshot(info))
})
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
peers.values.map { info in
TransportPeerSnapshot(
id: info.id,
nickname: info.nickname,
isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen
)
}
}
// Notify non-UI listeners
peerSnapshotSubject.send(transportPeers)
// Notify UI on MainActor via delegate
Task { @MainActor [weak self] in
self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers)
}
fullPeersPublisher.send(snapshot)
}
// MARK: - Consolidated Maintenance
@ -1479,7 +1413,6 @@ final class SimplifiedBluetoothService: NSObject {
for peerID in disconnectedPeers {
self.delegate?.didDisconnectFromPeer(peerID)
}
self.peersPublisher.send(self.getPeers())
self.delegate?.didUpdatePeerList(currentPeerIDs)
}
}
@ -1505,7 +1438,7 @@ final class SimplifiedBluetoothService: NSObject {
// MARK: - CBCentralManagerDelegate
extension SimplifiedBluetoothService: CBCentralManagerDelegate {
extension BLEService: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
if central.state == .poweredOn {
// Start scanning - use allow duplicates for faster discovery when active
@ -1527,7 +1460,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate {
#endif
central.scanForPeripherals(
withServices: [SimplifiedBluetoothService.serviceUUID],
withServices: [BLEService.serviceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates]
)
@ -1630,7 +1563,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate {
SecureLogger.log("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: SecureLogger.session, level: .debug)
// Discover services
peripheral.discoverServices([SimplifiedBluetoothService.serviceUUID])
peripheral.discoverServices([BLEService.serviceUUID])
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
@ -1674,8 +1607,7 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate {
if let peerID = peerID {
self.delegate?.didDisconnectFromPeer(peerID)
}
self.peersPublisher.send(self.getPeers())
self.publishFullPeerData() // NEW: Publish full peer data
self.publishFullPeerData()
self.delegate?.didUpdatePeerList(currentPeerIDs)
}
}
@ -1692,14 +1624,14 @@ extension SimplifiedBluetoothService: CBCentralManagerDelegate {
// MARK: - CBPeripheralDelegate
extension SimplifiedBluetoothService: CBPeripheralDelegate {
extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error = error {
SecureLogger.log("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: SecureLogger.session, level: .error)
// Retry service discovery after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard peripheral.state == .connected else { return }
peripheral.discoverServices([SimplifiedBluetoothService.serviceUUID])
peripheral.discoverServices([BLEService.serviceUUID])
}
return
}
@ -1709,14 +1641,14 @@ extension SimplifiedBluetoothService: CBPeripheralDelegate {
return
}
guard let service = services.first(where: { $0.uuid == SimplifiedBluetoothService.serviceUUID }) else {
guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else {
// Not a BitChat peer - disconnect
centralManager?.cancelPeripheralConnection(peripheral)
return
}
// Discovering BLE characteristics
peripheral.discoverCharacteristics([SimplifiedBluetoothService.characteristicUUID], for: service)
peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
@ -1725,7 +1657,7 @@ extension SimplifiedBluetoothService: CBPeripheralDelegate {
return
}
guard let characteristic = service.characteristics?.first(where: { $0.uuid == SimplifiedBluetoothService.characteristicUUID }) else {
guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else {
SecureLogger.log("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .warning)
return
}
@ -1788,7 +1720,7 @@ extension SimplifiedBluetoothService: CBPeripheralDelegate {
}
// Use the packet's senderID as the peer identifier
let senderID = dataToHexString(packet.senderID)
let senderID = packet.senderID.hexEncodedString()
// Only log non-announce packets
if packet.type != MessageType.announce.rawValue {
SecureLogger.log("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: SecureLogger.session, level: .debug)
@ -1834,7 +1766,7 @@ extension SimplifiedBluetoothService: CBPeripheralDelegate {
SecureLogger.log("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: SecureLogger.session, level: .warning)
// Check if our service was invalidated (peer app quit)
let hasOurService = peripheral.services?.contains { $0.uuid == SimplifiedBluetoothService.serviceUUID } ?? false
let hasOurService = peripheral.services?.contains { $0.uuid == BLEService.serviceUUID } ?? false
if !hasOurService {
// Service is gone - disconnect
@ -1842,7 +1774,7 @@ extension SimplifiedBluetoothService: CBPeripheralDelegate {
centralManager?.cancelPeripheralConnection(peripheral)
} else {
// Try to rediscover
peripheral.discoverServices([SimplifiedBluetoothService.serviceUUID])
peripheral.discoverServices([BLEService.serviceUUID])
}
}
@ -1863,7 +1795,7 @@ extension SimplifiedBluetoothService: CBPeripheralDelegate {
// MARK: - CBPeripheralManagerDelegate
extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
extension BLEService: CBPeripheralManagerDelegate {
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
SecureLogger.log("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: SecureLogger.session, level: .debug)
@ -1873,14 +1805,14 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
// Create characteristic
characteristic = CBMutableCharacteristic(
type: SimplifiedBluetoothService.characteristicUUID,
type: BLEService.characteristicUUID,
properties: [.notify, .write, .writeWithoutResponse, .read],
value: nil,
permissions: [.readable, .writeable]
)
// Create service
let service = CBMutableService(type: SimplifiedBluetoothService.serviceUUID, primary: true)
let service = CBMutableService(type: BLEService.serviceUUID, primary: true)
service.characteristics = [characteristic!]
// Add service (advertising will start in didAdd delegate)
@ -1941,7 +1873,6 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
self.delegate?.didDisconnectFromPeer(peerID)
self.peersPublisher.send(self.getPeers())
self.delegate?.didUpdatePeerList(currentPeerIDs)
}
}
@ -2019,7 +1950,7 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
if let packet = BinaryProtocol.decode(data) {
// Use the packet's senderID as the peer identifier
let senderID = dataToHexString(packet.senderID)
let senderID = packet.senderID.hexEncodedString()
// Only log non-announce packets
if packet.type != MessageType.announce.rawValue {
SecureLogger.log("📦 Decoded packet type: \(packet.type) from sender: \(senderID)", category: SecureLogger.session, level: .debug)
@ -2052,45 +1983,15 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
SecureLogger.log("❌ Failed to decode packet from central, full data: \(data.map { String(format: "%02x", $0) }.joined(separator: " "))", category: SecureLogger.session, level: .error)
}
}
}
// MARK: - Helper Functions
private func hexStringToData(_ hex: String) -> Data {
var data = Data()
var tempID = hex
while tempID.count >= 2 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
data.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
if tempID.count == 1 {
if let byte = UInt8(tempID, radix: 16) {
data.append(byte)
}
}
return data
}
private func dataToHexString(_ data: Data) -> String {
return data.map { String(format: "%02x", $0) }.joined()
}
}
}
// MARK: - Advertising Builders & Alias Rotation
extension SimplifiedBluetoothService {
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
let digest = SHA256.hash(data: publicKey)
let hex = digest.map { String(format: "%02x", $0) }.joined()
return String(hex.prefix(16))
}
extension BLEService {
private func buildAdvertisementData() -> [String: Any] {
let data: [String: Any] = [
CBAdvertisementDataServiceUUIDsKey: [SimplifiedBluetoothService.serviceUUID]
CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID]
]
// No Local Name for privacy
return data
@ -2098,300 +1999,3 @@ extension SimplifiedBluetoothService {
// No alias rotation or advertising restarts required.
}
// MARK: - Nostr Embedding Helpers
extension SimplifiedBluetoothService {
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message
/// for transport over Nostr DMs. The payload is a plaintext typed NoisePayload
/// (no inner Noise encryption; NIP-17 provides transport-layer E2E).
func buildNostrEmbeddedPrivateMessageContent(content: String, to recipientPeerID: String, messageID: String) -> String? {
// TLV-encode the private message
let pm = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlv = pm.encode() else { return nil }
// Prefix with NoisePayloadType
var payload = Data([NoisePayloadType.privateMessage.rawValue])
payload.append(tlv)
// Build BitChat packet (noiseEncrypted type used as a typed envelope)
// Determine correct 8-byte recipient ID (peerID) to embed
let recipientIDHex: String = {
if let maybeData = Data(hexString: recipientPeerID) {
if maybeData.count == 32 {
// Treat as Noise static public key; derive peerID from fingerprint
return Self.derivePeerID(fromPublicKey: maybeData)
} else if maybeData.count == 8 {
// Already an 8-byte peer ID
return recipientPeerID
}
}
// Fallback (should not happen): use myPeerID to avoid dropping
return recipientPeerID.count == 16 ? recipientPeerID : myPeerID
}()
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: hexStringToData(myPeerID),
recipientID: hexStringToData(recipientIDHex),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
guard let data = packet.toBinaryData() else { return nil }
return "bitchat1:" + Self.base64URLEncode(data)
}
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack
/// for transport over Nostr DMs. Payload is plaintext typed NoisePayload.
func buildNostrEmbeddedAckContent(type: NoisePayloadType, messageID: String, to recipientPeerID: String) -> String? {
guard type == .delivered || type == .readReceipt else { return nil }
var payload = Data([type.rawValue])
payload.append(Data(messageID.utf8))
// Determine correct 8-byte recipient ID (peerID) to embed
let recipientIDHex: String = {
if let maybeData = Data(hexString: recipientPeerID) {
if maybeData.count == 32 {
return Self.derivePeerID(fromPublicKey: maybeData)
} else if maybeData.count == 8 {
return recipientPeerID
}
}
return recipientPeerID.count == 16 ? recipientPeerID : myPeerID
}()
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: hexStringToData(myPeerID),
recipientID: hexStringToData(recipientIDHex),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
guard let data = packet.toBinaryData() else { return nil }
return "bitchat1:" + Self.base64URLEncode(data)
}
/// Base64url encode without padding
private static func base64URLEncode(_ data: Data) -> String {
let b64 = data.base64EncodedString()
let urlSafe = b64
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return urlSafe
}
}
// MARK: - Message Deduplicator
/// Efficient message deduplication with time-based cleanup
private class MessageDeduplicator {
private struct Entry {
let messageID: String
let timestamp: Date
}
private var entries: [Entry] = []
private var lookup = Set<String>()
private let lock = NSLock()
private let maxAge: TimeInterval = 300 // 5 minutes
private let maxCount = 1000
/// Check if message is duplicate and add if not
func isDuplicate(_ messageID: String) -> Bool {
lock.lock()
defer { lock.unlock() }
// Clean old entries first
cleanupOldEntries()
if lookup.contains(messageID) {
return true
}
// Add new entry
entries.append(Entry(messageID: messageID, timestamp: Date()))
lookup.insert(messageID)
// Efficient cleanup when over limit
if entries.count > maxCount {
// Remove oldest 100 entries
let toRemove = entries.prefix(100)
toRemove.forEach { lookup.remove($0.messageID) }
entries.removeFirst(100)
}
return false
}
/// Add an ID without checking (for announce-back tracking)
func markProcessed(_ messageID: String) {
lock.lock()
defer { lock.unlock() }
if !lookup.contains(messageID) {
entries.append(Entry(messageID: messageID, timestamp: Date()))
lookup.insert(messageID)
}
}
/// Check if ID exists without adding
func contains(_ messageID: String) -> Bool {
lock.lock()
defer { lock.unlock() }
return lookup.contains(messageID)
}
/// Clear all entries
func reset() {
lock.lock()
defer { lock.unlock() }
entries.removeAll()
lookup.removeAll()
}
/// Periodic cleanup (called from timer)
func cleanup() {
lock.lock()
defer { lock.unlock() }
cleanupOldEntries()
// Additional memory optimization if needed
if entries.capacity > maxCount * 2 {
entries.reserveCapacity(maxCount)
}
}
private func cleanupOldEntries() {
let cutoff = Date().addingTimeInterval(-maxAge)
// Remove all entries older than cutoff
while let first = entries.first, first.timestamp < cutoff {
lookup.remove(first.messageID)
entries.removeFirst()
}
}
}
// MARK: - Supporting Types
private struct AnnouncementPacket {
let nickname: String
let publicKey: Data
private enum TLVType: UInt8 {
case nickname = 0x01
case noisePublicKey = 0x02
}
func encode() -> Data? {
var data = Data()
// TLV for nickname
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
data.append(TLVType.nickname.rawValue)
data.append(UInt8(nicknameData.count))
data.append(nicknameData)
// TLV for public key
guard publicKey.count <= 255 else { return nil }
data.append(TLVType.noisePublicKey.rawValue)
data.append(UInt8(publicKey.count))
data.append(publicKey)
return data
}
static func decode(from data: Data) -> AnnouncementPacket? {
var offset = 0
var nickname: String?
var publicKey: Data?
while offset + 2 <= data.count {
guard let type = TLVType(rawValue: data[offset]) else { return nil }
offset += 1
let length = Int(data[offset])
offset += 1
guard offset + length <= data.count else { return nil }
let value = data[offset..<offset + length]
offset += length
switch type {
case .nickname:
nickname = String(data: value, encoding: .utf8)
case .noisePublicKey:
publicKey = Data(value)
}
}
guard let nickname = nickname, let publicKey = publicKey else { return nil }
return AnnouncementPacket(nickname: nickname, publicKey: publicKey)
}
}
private struct PrivateMessagePacket {
let messageID: String
let content: String
private enum TLVType: UInt8 {
case messageID = 0x00
case content = 0x01
}
func encode() -> Data? {
var data = Data()
// TLV for messageID
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }
data.append(TLVType.messageID.rawValue)
data.append(UInt8(messageIDData.count))
data.append(messageIDData)
// TLV for content
guard let contentData = content.data(using: .utf8), contentData.count <= 255 else { return nil }
data.append(TLVType.content.rawValue)
data.append(UInt8(contentData.count))
data.append(contentData)
return data
}
static func decode(from data: Data) -> PrivateMessagePacket? {
var offset = 0
var messageID: String?
var content: String?
while offset + 2 <= data.count {
guard let type = TLVType(rawValue: data[offset]) else { return nil }
offset += 1
let length = Int(data[offset])
offset += 1
guard offset + length <= data.count else { return nil }
let value = data[offset..<offset + length]
offset += length
switch type {
case .messageID:
messageID = String(data: value, encoding: .utf8)
case .content:
content = String(data: value, encoding: .utf8)
}
}
guard let messageID = messageID, let content = content else { return nil }
return PrivateMessagePacket(messageID: messageID, content: content)
}
}

View File

@ -19,9 +19,9 @@ enum CommandResult {
@MainActor
class CommandProcessor {
weak var chatViewModel: ChatViewModel?
weak var meshService: SimplifiedBluetoothService?
weak var meshService: Transport?
init(chatViewModel: ChatViewModel? = nil, meshService: SimplifiedBluetoothService? = nil) {
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil) {
self.chatViewModel = chatViewModel
self.meshService = meshService
}
@ -127,7 +127,7 @@ class CommandProcessor {
}
} else {
// In public chat
meshService?.sendMessage(emoteContent)
meshService?.sendMessage(emoteContent, mentions: [])
}
return .handled
@ -145,7 +145,7 @@ class CommandProcessor {
var blockedNicknames: [String] = []
if let peers = meshService?.getPeerNicknames() {
for (peerID, nickname) in peers {
if let fingerprint = meshService?.getPeerFingerprint(peerID),
if let fingerprint = meshService?.getFingerprint(for: peerID),
blockedUsers.contains(fingerprint) {
blockedNicknames.append(nickname)
}
@ -160,7 +160,7 @@ class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getPeerFingerprint(peerID) else {
let fingerprint = meshService?.getFingerprint(for: peerID) else {
return .error(message: "cannot block \(nickname): not found or unable to verify identity")
}
@ -200,7 +200,7 @@ class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getPeerFingerprint(peerID) else {
let fingerprint = meshService?.getFingerprint(for: peerID) else {
return .error(message: "cannot unblock \(nickname): not found")
}

View File

@ -0,0 +1,46 @@
import Foundation
/// Routes messages between BLE and Nostr transports
@MainActor
final class MessageRouter {
private let mesh: Transport
private let nostr: NostrTransport
init(mesh: Transport, nostr: NostrTransport) {
self.mesh = mesh
self.nostr = nostr
self.nostr.senderPeerID = mesh.myPeerID
}
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
if mesh.isPeerConnected(peerID) {
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else {
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
if mesh.isPeerConnected(peerID) {
mesh.sendReadReceipt(receipt, to: peerID)
} else {
nostr.sendReadReceipt(receipt, to: peerID)
}
}
func sendDeliveryAck(_ messageID: String, to peerID: String) {
if mesh.isPeerConnected(peerID) {
mesh.sendDeliveryAck(for: messageID, to: peerID)
} else {
nostr.sendDeliveryAck(for: messageID, to: peerID)
}
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else {
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
}
}

View File

@ -11,7 +11,7 @@
///
/// High-level encryption service that manages Noise Protocol sessions for secure
/// peer-to-peer communication in BitChat. Acts as the bridge between the transport
/// layer (SimplifiedBluetoothService) and the cryptographic layer (NoiseProtocol).
/// layer (BLEService) and the cryptographic layer (NoiseProtocol).
///
/// ## Overview
/// This service provides a simplified API for establishing and managing encrypted
@ -60,7 +60,7 @@
/// ```
///
/// ## Integration Points
/// - **SimplifiedBluetoothService**: Calls this service for all private messages
/// - **BLEService**: Calls this service for all private messages
/// - **ChatViewModel**: Monitors encryption status for UI indicators
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
/// - **KeychainManager**: Secure storage for identity keys

View File

@ -0,0 +1,84 @@
import Foundation
import Combine
// Minimal Nostr transport conforming to Transport for offline sending
final class NostrTransport: Transport {
weak var delegate: BitchatDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
Just([]).eraseToAnyPublisher()
}
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
// Provide BLE short peer ID for BitChat embedding
var senderPeerID: String = ""
var myPeerID: String { senderPeerID }
var myNickname: String { "" }
func setNickname(_ nickname: String) { /* not used for Nostr */ }
func startServices() { /* no-op */ }
func stopServices() { /* no-op */ }
func emergencyDisconnectAll() { /* no-op */ }
func isPeerConnected(_ peerID: String) -> Bool { false }
func getPeerNicknames() -> [String : String] { [:] }
func getFingerprint(for peerID: String) -> String? { nil }
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: String) { /* no-op */ }
func getNoiseService() -> NoiseEncryptionService { NoiseEncryptionService() }
// Public broadcast not supported over Nostr here
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
Task { @MainActor in
guard let noiseKey = Data(hexString: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let recipientNostrPubkey = fav.peerNostrPublicKey else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID),
let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return }
NostrRelayManager.shared.sendEvent(event)
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
Task { @MainActor in
guard let noiseKey = Data(hexString: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let recipientNostrPubkey = fav.peerNostrPublicKey else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: receipt.originalMessageID, recipientPeerID: peerID, senderPeerID: senderPeerID),
let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return }
NostrRelayManager.shared.sendEvent(event)
}
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
Task { @MainActor in
guard let noiseKey = Data(hexString: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let recipientNostrPubkey = fav.peerNostrPublicKey else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID),
let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return }
NostrRelayManager.shared.sendEvent(event)
}
}
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: String) {
Task { @MainActor in
guard let noiseKey = Data(hexString: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let recipientNostrPubkey = fav.peerNostrPublicKey else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID),
let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return }
NostrRelayManager.shared.sendEvent(event)
}
}
}

View File

@ -18,9 +18,9 @@ class PrivateChatManager: ObservableObject {
private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
weak var meshService: SimplifiedBluetoothService?
weak var meshService: Transport?
init(meshService: SimplifiedBluetoothService? = nil) {
init(meshService: Transport? = nil) {
self.meshService = meshService
}
@ -29,7 +29,7 @@ class PrivateChatManager: ObservableObject {
selectedPeer = peerID
// Store fingerprint for persistence across reconnections
if let fingerprint = meshService?.getPeerFingerprint(peerID) {
if let fingerprint = meshService?.getFingerprint(for: peerID) {
selectedPeerFingerprint = fingerprint
}
@ -130,7 +130,7 @@ class PrivateChatManager: ObservableObject {
// Find peer with matching fingerprint
for (peerID, _) in peers {
if meshService?.getPeerFingerprint(peerID) == fingerprint {
if meshService?.getFingerprint(for: peerID) == fingerprint {
selectedPeer = peerID
break
}
@ -186,4 +186,4 @@ class PrivateChatManager: ObservableObject {
// Send through mesh service's read receipt method
meshService?.sendReadReceipt(receipt, to: senderPeerID)
}
}
}

View File

@ -0,0 +1,57 @@
import Foundation
import Combine
/// Abstract transport interface used by ChatViewModel and services.
/// BLEService implements this protocol; a future Nostr transport can too.
struct TransportPeerSnapshot {
let id: String
let nickname: String
let isConnected: Bool
let noisePublicKey: Data?
let lastSeen: Date
}
protocol Transport: AnyObject {
// Peer events (preferred over publishers for UI)
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
// Event sink
var delegate: BitchatDelegate? { get set }
// Identity
var myPeerID: String { get }
var myNickname: String { get }
func setNickname(_ nickname: String)
// Lifecycle
func startServices()
func stopServices()
func emergencyDisconnectAll()
// Connectivity and peers
func isPeerConnected(_ peerID: String) -> Bool
func getPeerNicknames() -> [String: String]
// Protocol utilities
func getFingerprint(for peerID: String) -> String?
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState
func triggerHandshake(with peerID: String)
func getNoiseService() -> NoiseEncryptionService
// Messaging
func sendMessage(_ content: String, mentions: [String])
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String)
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String)
func sendFavoriteNotification(to peerID: String, isFavorite: Bool)
func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: String)
// Peer snapshots (for non-UI services)
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
func currentPeerSnapshots() -> [TransportPeerSnapshot]
}
protocol TransportPeerEventsDelegate: AnyObject {
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
}
extension BLEService: Transport {}

View File

@ -13,7 +13,7 @@ import CryptoKit
/// Single source of truth for peer state, combining mesh connectivity and favorites
@MainActor
class UnifiedPeerService: ObservableObject {
class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Published Properties
@ -26,13 +26,13 @@ class UnifiedPeerService: ObservableObject {
private var peerIndex: [String: BitchatPeer] = [:]
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
private let meshService: SimplifiedBluetoothService
private let meshService: Transport
private let favoritesService = FavoritesPersistenceService.shared
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(meshService: SimplifiedBluetoothService) {
init(meshService: Transport) {
self.meshService = meshService
// Subscribe to changes from both services
@ -47,14 +47,8 @@ class UnifiedPeerService: ObservableObject {
// MARK: - Setup
private func setupSubscriptions() {
// Subscribe to mesh peer updates
meshService.fullPeersPublisher
.combineLatest(favoritesService.$favorites)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.updatePeers()
}
.store(in: &cancellables)
// Subscribe to mesh peer updates via delegate (preferred over publishers)
meshService.peerEventsDelegate = self
// Also listen for favorite change notifications
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
@ -64,11 +58,16 @@ class UnifiedPeerService: ObservableObject {
}
.store(in: &cancellables)
}
// TransportPeerEventsDelegate
func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) {
updatePeers()
}
// MARK: - Core Update Logic
private func updatePeers() {
let meshPeers = meshService.fullPeersPublisher.value
let meshPeers = meshService.currentPeerSnapshots()
let favorites = favoritesService.favorites
var enrichedPeers: [BitchatPeer] = []
@ -76,11 +75,11 @@ class UnifiedPeerService: ObservableObject {
var addedPeerIDs: Set<String> = []
// Phase 1: Add all connected mesh peers
for (peerID, peerInfo) in meshPeers where peerInfo.isConnected {
for peerInfo in meshPeers where peerInfo.isConnected {
let peerID = peerInfo.id
guard peerID != meshService.myPeerID else { continue } // Never add self
let peer = buildPeerFromMesh(
peerID: peerID,
peerInfo: peerInfo,
favorites: favorites
)
@ -162,12 +161,11 @@ class UnifiedPeerService: ObservableObject {
// MARK: - Peer Building Helpers
private func buildPeerFromMesh(
peerID: String,
peerInfo: SimplifiedBluetoothService.PeerInfoSnapshot,
peerInfo: TransportPeerSnapshot,
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship]
) -> BitchatPeer {
var peer = BitchatPeer(
id: peerID,
id: peerInfo.id,
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
nickname: peerInfo.nickname,
lastSeen: peerInfo.lastSeen,
@ -365,7 +363,7 @@ class UnifiedPeerService: ObservableObject {
}
// Try to get from mesh service
if let fingerprint = meshService.getPeerFingerprint(peerID) {
if let fingerprint = meshService.getFingerprint(for: peerID) {
fingerprintCache[peerID] = fingerprint
return fingerprint
}

View File

@ -0,0 +1,87 @@
import Foundation
// MARK: - Message Deduplicator (shared)
final class MessageDeduplicator {
private struct Entry {
let messageID: String
let timestamp: Date
}
private var entries: [Entry] = []
private var lookup = Set<String>()
private let lock = NSLock()
private let maxAge: TimeInterval = 300 // 5 minutes
private let maxCount = 1000
/// Check if message is duplicate and add if not
func isDuplicate(_ messageID: String) -> Bool {
lock.lock()
defer { lock.unlock() }
cleanupOldEntries()
if lookup.contains(messageID) {
return true
}
entries.append(Entry(messageID: messageID, timestamp: Date()))
lookup.insert(messageID)
if entries.count > maxCount {
let toRemove = entries.prefix(100)
toRemove.forEach { lookup.remove($0.messageID) }
entries.removeFirst(100)
}
return false
}
/// Add an ID without checking (for announce-back tracking)
func markProcessed(_ messageID: String) {
lock.lock()
defer { lock.unlock() }
if !lookup.contains(messageID) {
entries.append(Entry(messageID: messageID, timestamp: Date()))
lookup.insert(messageID)
}
}
/// Check if ID exists without adding
func contains(_ messageID: String) -> Bool {
lock.lock()
defer { lock.unlock() }
return lookup.contains(messageID)
}
/// Clear all entries
func reset() {
lock.lock()
defer { lock.unlock() }
entries.removeAll()
lookup.removeAll()
}
/// Periodic cleanup
func cleanup() {
lock.lock()
defer { lock.unlock() }
cleanupOldEntries()
if entries.capacity > maxCount * 2 {
entries.reserveCapacity(maxCount)
}
}
private func cleanupOldEntries() {
let cutoff = Date().addingTimeInterval(-maxAge)
while let first = entries.first, first.timestamp < cutoff {
lookup.remove(first.messageID)
entries.removeFirst()
}
}
}

View File

@ -0,0 +1,20 @@
import Foundation
struct PeerIDResolver {
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
static func toShortID(_ id: String) -> String {
if id.count == 64, let data = Data(hexString: id) {
return PeerIDUtils.derivePeerID(fromPublicKey: data)
}
return id
}
static func isShortID(_ id: String) -> Bool {
return id.count == 16 && Data(hexString: id) != nil
}
static func isNoiseKeyHex(_ id: String) -> Bool {
return id.count == 64 && Data(hexString: id) != nil
}
}

View File

@ -23,7 +23,7 @@
///
/// ## Architecture
/// The ViewModel acts as:
/// - **BitchatDelegate**: Receives messages and events from SimplifiedBluetoothService
/// - **BitchatDelegate**: Receives messages and events from BLEService
/// - **State Manager**: Maintains all UI-relevant state with @Published properties
/// - **Command Processor**: Handles IRC-style commands (/msg, /who, etc.)
/// - **Message Router**: Directs messages to appropriate chats (public/private)
@ -116,6 +116,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Service Delegates
private let commandProcessor: CommandProcessor
private let messageRouter: MessageRouter
private let privateChatManager: PrivateChatManager
private let unifiedPeerService: UnifiedPeerService
private let autocompleteService: AutocompleteService
@ -181,7 +182,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Services and Storage
var meshService = SimplifiedBluetoothService()
var meshService: Transport = BLEService()
private var nostrRelayManager: NostrRelayManager?
// PeerManager replaced by UnifiedPeerService
private var processedNostrEvents = Set<String>() // Simple deduplication
@ -274,6 +275,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.commandProcessor = CommandProcessor()
self.privateChatManager = PrivateChatManager(meshService: meshService)
self.unifiedPeerService = UnifiedPeerService(meshService: meshService)
let nostrTransport = NostrTransport()
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
self.autocompleteService = AutocompleteService()
// Wire up dependencies
@ -857,14 +860,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Trigger UI update for sent message
objectWillChange.send()
// Send via appropriate transport
if isConnected {
// Send via mesh
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
} else if isMutualFavorite && hasNostrKey,
let recipientNostrPubkey = favoriteStatus?.peerNostrPublicKey {
// Mutual favorite offline - send via Nostr
sendViaNostr(content, to: recipientNostrPubkey, recipientPeerID: peerID, messageId: messageID)
// Send via appropriate transport (BLE if connected, else Nostr when possible)
if isConnected || (isMutualFavorite && hasNostrKey) {
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
} else {
// Update delivery status to failed
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
@ -1304,6 +1302,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
@MainActor
@objc private func userDidTakeScreenshot() {
// Send screenshot notification based on current context
let screenshotMessage = "* \(nickname) took a screenshot *"
@ -1317,7 +1316,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
switch sessionState {
case .established:
// Send the message directly without going through sendPrivateMessage to avoid local echo
meshService.sendPrivateMessage(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString)
messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString)
default:
// Don't send screenshot notification if no session exists
SecureLogger.log("Skipping screenshot notification to \(peerID) - no established session", category: SecureLogger.security, level: .debug)
@ -1414,7 +1413,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// The radical simplification plan says to accept occasional loss
} else if meshService.getPeerNicknames()[actualPeerID] != nil {
// Use mesh for connected peers (default behavior)
meshService.sendReadReceipt(receipt, to: actualPeerID)
messageRouter.sendReadReceipt(receipt, to: actualPeerID)
} else {
// Skip read receipts for offline peers - fire and forget principle
}
@ -1447,7 +1446,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Send Nostr read ACKs if peer has Nostr capability
if let nostrPubkey = peerNostrPubkey {
if peerNostrPubkey != nil {
// Check messages under both ephemeral peer ID and stable Noise key
let messagesToAck = getPrivateChatMessages(for: peerID)
@ -1459,7 +1458,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if !sentReadReceipts.contains(message.id) {
// Use stable Noise key hex if available; else fall back to peerID
let recipPeer = (Data(hexString: peerID) != nil) ? peerID : (unifiedPeerService.getPeer(by: peerID)?.noisePublicKey.hexEncodedString() ?? peerID)
sendNostrAcknowledgment(messageId: message.id, to: nostrPubkey, type: "READ", recipientPeerID: recipPeer)
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
messageRouter.sendReadReceipt(receipt, to: recipPeer)
sentReadReceipts.insert(message.id)
}
}
@ -2324,6 +2324,74 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
sendHapticFeedback(for: message)
}
}
// Low-level BLE events
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
Task { @MainActor in
switch type {
case .privateMessage:
guard let pm = PrivateMessagePacket.decode(from: payload) else { return }
let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
let pmMentions = parseMentions(from: pm.content)
let msg = BitchatMessage(
id: pm.messageID,
sender: senderName,
content: pm.content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: peerID,
mentions: pmMentions.isEmpty ? nil : pmMentions
)
handlePrivateMessage(msg)
// Send delivery ACK back over BLE
meshService.sendDeliveryAck(for: pm.messageID, to: peerID)
case .delivered:
guard let messageID = String(data: payload, encoding: .utf8) else { return }
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())
objectWillChange.send()
}
}
case .readReceipt:
guard let messageID = String(data: payload, encoding: .utf8) else { return }
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .read(by: name, at: Date())
objectWillChange.send()
}
}
}
}
}
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
Task { @MainActor in
let publicMentions = parseMentions(from: content)
let msg = BitchatMessage(
id: UUID().uuidString,
sender: nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: publicMentions.isEmpty ? nil : publicMentions
)
handlePublicMessage(msg)
checkForMentions(msg)
sendHapticFeedback(for: msg)
}
}
// Mention parsing moved from BLE use the existing non-optional helper below
// MARK: - Peer Connection Events
func didConnectToPeer(_ peerID: String) {
@ -2688,122 +2756,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
@MainActor
private func sendViaNostr(_ content: String, to recipientNostrPubkey: String, recipientPeerID: String, messageId: String) {
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("No Nostr identity available", category: SecureLogger.session, level: .error)
return
}
// Decode npub to hex if necessary
var recipientHexPubkey = recipientNostrPubkey
if recipientNostrPubkey.hasPrefix("npub") {
do {
let (hrp, data) = try Bech32.decode(recipientNostrPubkey)
if hrp == "npub" {
recipientHexPubkey = data.hexEncodedString()
SecureLogger.log("Decoded npub to hex: \(recipientHexPubkey)", category: SecureLogger.session, level: .debug)
}
} catch {
SecureLogger.log("Failed to decode recipient npub: \(error)", category: SecureLogger.session, level: .error)
return
}
}
// Build embedded BitChat packet content (bitchat1:...)
guard let embeddedContent = meshService.buildNostrEmbeddedPrivateMessageContent(
content: content,
to: recipientPeerID,
messageID: messageId
) else {
SecureLogger.log("Failed to build embedded BitChat content for Nostr", category: SecureLogger.session, level: .error)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(
content: embeddedContent,
recipientPubkey: recipientHexPubkey,
senderIdentity: senderIdentity
) else {
SecureLogger.log("Failed to create Nostr message for recipient: \(recipientHexPubkey)", category: SecureLogger.session, level: .error)
return
}
SecureLogger.log("📝 Created Nostr event ID: \(event.id.prefix(16))... for recipient: \(recipientHexPubkey.prefix(16))...",
category: SecureLogger.session, level: .info)
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("Sent Nostr message via relay", category: SecureLogger.session, level: .debug)
// Update delivery status to sent for Nostr messages
// Need to find which peerID (ephemeral or Noise key) contains this message
var messageUpdated = false
// First try to find the message in any private chat
for (chatPeerID, messages) in privateChats {
if let index = messages.firstIndex(where: { $0.id == messageId }) {
privateChats[chatPeerID]?[index].deliveryStatus = .sent
messageUpdated = true
SecureLogger.log("✅ Updated delivery status to sent for message \(messageId) in chat \(chatPeerID)",
category: SecureLogger.session, level: .debug)
objectWillChange.send()
break
}
}
if !messageUpdated {
SecureLogger.log("⚠️ Could not find message \(messageId) to update delivery status",
category: SecureLogger.session, level: .warning)
}
} else {
SecureLogger.log("NostrRelayManager is nil - cannot send message", category: SecureLogger.session, level: .error)
}
}
@MainActor
private func sendNostrAcknowledgment(messageId: String, to recipientNostrPubkey: String, type: String, recipientPeerID: String) {
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("No Nostr identity for sending ACK", category: SecureLogger.session, level: .error)
return
}
// Decode npub to hex if necessary
var recipientHexPubkey = recipientNostrPubkey
if recipientNostrPubkey.hasPrefix("npub") {
do {
let (hrp, data) = try Bech32.decode(recipientNostrPubkey)
if hrp == "npub" {
recipientHexPubkey = data.hexEncodedString()
}
} catch {
SecureLogger.log("Failed to decode recipient npub for ACK: \(error)",
category: SecureLogger.session, level: .error)
return
}
}
// Build embedded BitChat ACK content
let ackType: NoisePayloadType? = (type == "DELIVERED") ? .delivered : (type == "READ" ? .readReceipt : nil)
guard let ackTypeUnwrapped = ackType,
let ackContent = meshService.buildNostrEmbeddedAckContent(type: ackTypeUnwrapped, messageID: messageId, to: recipientPeerID),
let event = try? NostrProtocol.createPrivateMessage(
content: ackContent,
recipientPubkey: recipientHexPubkey,
senderIdentity: senderIdentity
) else {
SecureLogger.log("Failed to create Nostr ACK for message \(messageId)",
category: SecureLogger.session, level: .error)
return
}
SecureLogger.log("📮 Sending \(type) ACK for message \(messageId.prefix(16))... to \(recipientHexPubkey.prefix(16))...",
category: SecureLogger.session, level: .info)
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
}
}
// Removed inlined Nostr send helpers in favor of MessageRouter
@MainActor
private func setupNostrMessageHandling() {
@ -2893,7 +2846,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
switch noisePayload.type {
case .privateMessage:
guard let (messageId, messageContent) = Self.decodePrivateMessageTLV(noisePayload.data) else { return }
guard let pm = PrivateMessagePacket.decode(from: noisePayload.data) else { return }
let messageId = pm.messageID
let messageContent = pm.content
// Favorite/unfavorite notifications embedded as private messages
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
@ -2963,7 +2918,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key
if !wasReadBefore, let key = actualSenderNoiseKey {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED", recipientPeerID: key.hexEncodedString())
messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString())
}
if wasReadBefore {
@ -2975,7 +2930,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
unreadPrivateMessages.remove(ephemeralPeerID)
}
if !sentReadReceipts.contains(messageId), let key = actualSenderNoiseKey {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ", recipientPeerID: key.hexEncodedString())
let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname)
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
sentReadReceipts.insert(messageId)
}
} else {
@ -3101,26 +3057,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
return Data(base64Encoded: str)
}
// Decode PrivateMessagePacket TLV (local minimal decoder)
private static func decodePrivateMessageTLV(_ data: Data) -> (String, String)? {
var offset = 0
var messageID: String?
var content: String?
while offset + 2 <= data.count {
let type = data[offset]; offset += 1
let length = Int(data[offset]); offset += 1
guard offset + length <= data.count else { return nil }
let value = data[offset..<offset+length]
offset += length
if type == 0x00 {
messageID = String(data: value, encoding: .utf8)
} else if type == 0x01 {
content = String(data: value, encoding: .utf8)
}
}
if let id = messageID, let c = content { return (id, c) }
return nil
}
// Removed local TLV decoder; using PrivateMessagePacket.decode from Protocols
@MainActor
private func handleFavoriteNotificationFromMesh(_ content: String, from peerID: String, senderNickname: String) {
@ -3331,25 +3268,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
// Send favorite notification via Nostr when we don't have ephemeral peer ID
if let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
let recipientPeerID = noisePublicKey.hexEncodedString()
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
let event = try? NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) else { return }
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("Sent favorite notification via Nostr", category: SecureLogger.session, level: .debug)
}
}
let peerIDHex = noisePublicKey.hexEncodedString()
messageRouter.sendFavoriteNotification(to: peerIDHex, isFavorite: isFavorite)
}
@MainActor
@ -3369,34 +3289,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Try mesh first for connected peers
if meshService.isPeerConnected(peerID) {
meshService.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
SecureLogger.log("📤 Sent favorite notification via BLE to \(peerID)", category: SecureLogger.session, level: .debug)
} else if let key = noiseKey,
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: key),
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
// Send via Nostr for offline peers
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("❌ No Nostr identity for sending favorite notification", category: SecureLogger.session, level: .error)
return
}
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
} else if let key = noiseKey {
// Send via Nostr for offline peers (using router)
let recipientPeerID = key.hexEncodedString()
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
let event = try? NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) else {
SecureLogger.log("❌ Failed to create Nostr message for favorite notification", category: SecureLogger.session, level: .error)
return
}
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("📤 Sent favorite notification via Nostr to \(favoriteStatus.peerNickname)", category: SecureLogger.session, level: .debug)
} else {
SecureLogger.log("❌ NostrRelayManager is nil - cannot send favorite notification", category: SecureLogger.session, level: .error)
}
messageRouter.sendFavoriteNotification(to: recipientPeerID, isFavorite: isFavorite)
} else {
SecureLogger.log("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: SecureLogger.session, level: .warning)
}

View File

@ -1,5 +1,5 @@
//
// SimplifiedBluetoothServiceTests.swift
// BLEServiceTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
@ -10,13 +10,13 @@ import XCTest
import CoreBluetooth
@testable import bitchat
final class SimplifiedBluetoothServiceTests: XCTestCase {
final class BLEServiceTests: XCTestCase {
var service: MockSimplifiedBluetoothService!
var service: MockBLEService!
override func setUp() {
super.setUp()
service = MockSimplifiedBluetoothService()
service = MockBLEService()
service.myPeerID = "TEST1234"
service.mockNickname = "TestUser"
}

View File

@ -24,7 +24,7 @@ final class PrivateChatE2ETests: XCTestCase {
bob = createMockService(peerID: TestConstants.testPeerID2, nickname: TestConstants.testNickname2)
charlie = createMockService(peerID: TestConstants.testPeerID3, nickname: TestConstants.testNickname3)
// Delivery tracking is now handled internally by SimplifiedBluetoothService
// Delivery tracking is now handled internally by BLEService
}
override func tearDown() {
@ -92,13 +92,13 @@ final class PrivateChatE2ETests: XCTestCase {
// MARK: - Delivery Acknowledgment Tests
// NOTE: DeliveryTracker has been removed in SimplifiedBluetoothService.
// NOTE: DeliveryTracker has been removed in BLEService.
// Delivery tracking is now handled internally.
// MARK: - Message Retry Tests
// NOTE: MessageRetryService has been removed in SimplifiedBluetoothService.
// NOTE: MessageRetryService has been removed in BLEService.
// Retry logic is now handled internally.
@ -337,4 +337,4 @@ final class PrivateChatE2ETests: XCTestCase {
peer1.simulateConnectedPeer(peer2.peerID)
peer2.simulateConnectedPeer(peer1.peerID)
}
}
}

View File

@ -1,5 +1,5 @@
//
// MockSimplifiedBluetoothService.swift
// MockBLEService.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
@ -10,10 +10,10 @@ import Foundation
import CoreBluetooth
@testable import bitchat
// Mock implementation that mimics SimplifiedBluetoothService behavior
class MockSimplifiedBluetoothService: NSObject {
// Mock implementation that mimics BLEService behavior
class MockBLEService: NSObject {
// MARK: - Properties matching SimplifiedBluetoothService
// MARK: - Properties matching BLEService
weak var delegate: BitchatDelegate?
var myPeerID: String = "MOCK1234"
@ -46,7 +46,7 @@ class MockSimplifiedBluetoothService: NSObject {
super.init()
}
// MARK: - Methods matching SimplifiedBluetoothService
// MARK: - Methods matching BLEService
func setNickname(_ nickname: String) {
self.myNickname = nickname
@ -224,4 +224,7 @@ class MockSimplifiedBluetoothService: NSObject {
func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) {
sendPrivateMessage(content, to: recipientPeerID, recipientNickname: recipientNickname, messageID: messageID ?? UUID().uuidString)
}
}
}
// Backward compatibility for older tests
typealias MockSimplifiedBluetoothService = MockBLEService

View File

@ -10,5 +10,5 @@ import Foundation
import CoreBluetooth
@testable import bitchat
// Compatibility wrapper for old tests - please use MockSimplifiedBluetoothService directly
typealias MockBluetoothMeshService = MockSimplifiedBluetoothService
// Compatibility wrapper for old tests - please use MockBLEService directly
typealias MockBluetoothMeshService = MockBLEService

185
build.log

File diff suppressed because one or more lines are too long