mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
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:
@@ -7,6 +7,7 @@ plans/
|
||||
|
||||
## AI
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
-469
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 */,
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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: "")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+136
-532
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user