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
co-authored by jack
parent 3ebfa85e90
commit 6fbf7eee25
25 changed files with 873 additions and 1647 deletions
+1
View File
@@ -7,6 +7,7 @@ plans/
## AI
CLAUDE.md
AGENTS.md
## User settings
xcuserdata/
-469
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
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.
+66 -18
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 */,
);
+2 -2
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()
+78
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: "")
}
}
+13 -1
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
+116
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)
}
}
+14
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))
}
}
@@ -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)
}
}
+6 -6
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")
}
+46
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)
}
}
}
@@ -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
+84
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)
}
}
}
+5 -5
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)
}
}
}
+57
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 {}
+16 -18
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
}
+87
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()
}
}
}
+20
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
}
}
+96 -198
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)
}
@@ -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"
}
@@ -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)
}
}
}
@@ -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
@@ -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
+9 -176
View File
File diff suppressed because one or more lines are too long