From 6fbf7eee2538215e8715c37d772fab56147bf055 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 17 Aug 2025 15:17:04 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + AI_CONTEXT.md | 469 ------------ CLAUDE.md | 204 ------ bitchat.xcodeproj/project.pbxproj | 84 ++- bitchat/Models/BitchatPeer.swift | 4 +- bitchat/Nostr/NostrEmbeddedBitChat.swift | 78 ++ bitchat/Protocols/BitchatProtocol.swift | 14 +- bitchat/Protocols/Packets.swift | 116 +++ bitchat/Protocols/PeerID.swift | 14 + ...luetoothService.swift => BLEService.swift} | 668 ++++-------------- bitchat/Services/CommandProcessor.swift | 12 +- bitchat/Services/MessageRouter.swift | 46 ++ bitchat/Services/NoiseEncryptionService.swift | 4 +- bitchat/Services/NostrTransport.swift | 84 +++ bitchat/Services/PrivateChatManager.swift | 10 +- bitchat/Services/Transport.swift | 57 ++ bitchat/Services/UnifiedPeerService.swift | 34 +- bitchat/Utils/MessageDeduplicator.swift | 87 +++ bitchat/Utils/PeerIDResolver.swift | 20 + bitchat/ViewModels/ChatViewModel.swift | 294 +++----- ...rviceTests.swift => BLEServiceTests.swift} | 8 +- .../EndToEnd/PrivateChatE2ETests.swift | 8 +- ...oothService.swift => MockBLEService.swift} | 15 +- .../Mocks/MockBluetoothMeshService.swift | 4 +- build.log | 185 +---- 25 files changed, 873 insertions(+), 1647 deletions(-) delete mode 100644 AI_CONTEXT.md delete mode 100644 CLAUDE.md create mode 100644 bitchat/Nostr/NostrEmbeddedBitChat.swift create mode 100644 bitchat/Protocols/Packets.swift create mode 100644 bitchat/Protocols/PeerID.swift rename bitchat/Services/{SimplifiedBluetoothService.swift => BLEService.swift} (79%) create mode 100644 bitchat/Services/MessageRouter.swift create mode 100644 bitchat/Services/NostrTransport.swift create mode 100644 bitchat/Services/Transport.swift create mode 100644 bitchat/Utils/MessageDeduplicator.swift create mode 100644 bitchat/Utils/PeerIDResolver.swift rename bitchatTests/{SimplifiedBluetoothServiceTests.swift => BLEServiceTests.swift} (97%) rename bitchatTests/Mocks/{MockSimplifiedBluetoothService.swift => MockBLEService.swift} (95%) diff --git a/.gitignore b/.gitignore index 76236eff..37547a85 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ plans/ ## AI CLAUDE.md +AGENTS.md ## User settings xcuserdata/ diff --git a/AI_CONTEXT.md b/AI_CONTEXT.md deleted file mode 100644 index d8b82b55..00000000 --- a/AI_CONTEXT.md +++ /dev/null @@ -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::` -- Favorite: `FAVORITED:` or `UNFAVORITED:` -- Delivery ACK: `DELIVERED:` -- Read Receipt: `READ:` - -### 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. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 002fd08c..00000000 --- a/CLAUDE.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 18ace6a0..a13474f4 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -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 = ""; }; 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = ""; }; 049BD3982E506A12001A566B /* UnifiedPeerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedPeerService.swift; sourceTree = ""; }; + 049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEmbeddedBitChat.swift; sourceTree = ""; }; + 049BD39E2E51DBF4001A566B /* Packets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Packets.swift; sourceTree = ""; }; + 049BD39F2E51DBF4001A566B /* PeerID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerID.swift; sourceTree = ""; }; + 049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = ""; }; + 049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = ""; }; + 049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = ""; }; + 049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = ""; }; + 049BD3B12E51F319001A566B /* NostrTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrTransport.swift; sourceTree = ""; }; 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = ""; }; 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = ""; }; 11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = ""; }; @@ -168,13 +192,13 @@ 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoView.swift; sourceTree = ""; }; 78595178957244CBDF7E79B6 /* NostrRelayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrRelayManager.swift; sourceTree = ""; }; 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatE2ETests.swift; sourceTree = ""; }; - 8C6FDA03416FDB2157A0A8C7 /* SimplifiedBluetoothService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimplifiedBluetoothService.swift; sourceTree = ""; }; + 8C6FDA03416FDB2157A0A8C7 /* BLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEService.swift; sourceTree = ""; }; 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 = ""; }; 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FingerprintView.swift; sourceTree = ""; }; 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 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 = ""; }; + 980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = ""; }; 9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = ""; }; 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.swift; sourceTree = ""; }; A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -193,7 +217,7 @@ EF625BB3AD919322C01A46B2 /* BitchatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatApp.swift; sourceTree = ""; }; FC75901A0F0073B5BB8356E7 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = ""; }; FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = ""; }; - FE7CCF2BD78A3F3DAE6DA145 /* MockSimplifiedBluetoothService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSimplifiedBluetoothService.swift; sourceTree = ""; }; + FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = ""; }; FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -305,7 +329,7 @@ isa = PBXGroup; children = ( C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */, - FE7CCF2BD78A3F3DAE6DA145 /* MockSimplifiedBluetoothService.swift */, + FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */, ); path = Mocks; sourceTree = ""; @@ -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 = ""; @@ -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 */, ); diff --git a/bitchat/Models/BitchatPeer.swift b/bitchat/Models/BitchatPeer.swift index 65c34b46..beba8b2e 100644 --- a/bitchat/Models/BitchatPeer.swift +++ b/bitchat/Models/BitchatPeer.swift @@ -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() diff --git a/bitchat/Nostr/NostrEmbeddedBitChat.swift b/bitchat/Nostr/NostrEmbeddedBitChat.swift new file mode 100644 index 00000000..077861e8 --- /dev/null +++ b/bitchat/Nostr/NostrEmbeddedBitChat.swift @@ -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: "") + } +} diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index e11b6926..5d796b46 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -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 diff --git a/bitchat/Protocols/Packets.swift b/bitchat/Protocols/Packets.swift new file mode 100644 index 00000000..519c20c0 --- /dev/null +++ b/bitchat/Protocols/Packets.swift @@ -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.. 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.. String { + let digest = SHA256.hash(data: publicKey) + let hex = digest.map { String(format: "%02x", $0) }.joined() + return String(hex.prefix(16)) + } +} + diff --git a/bitchat/Services/SimplifiedBluetoothService.swift b/bitchat/Services/BLEService.swift similarity index 79% rename from bitchat/Services/SimplifiedBluetoothService.swift rename to bitchat/Services/BLEService.swift index e5095f5f..3bb19c2b 100644 --- a/bitchat/Services/SimplifiedBluetoothService.swift +++ b/bitchat/Services/BLEService.swift @@ -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() - 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() - 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.. 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.. { + 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) + } + } +} diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index a24bd3b1..24116ce0 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -18,9 +18,9 @@ class PrivateChatManager: ObservableObject { private var selectedPeerFingerprint: String? = nil var sentReadReceipts: Set = [] // 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) } -} \ No newline at end of file +} diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift new file mode 100644 index 00000000..b78587ff --- /dev/null +++ b/bitchat/Services/Transport.swift @@ -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 {} diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index c70b0d00..4c099d49 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -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() // 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 = [] // 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 } diff --git a/bitchat/Utils/MessageDeduplicator.swift b/bitchat/Utils/MessageDeduplicator.swift new file mode 100644 index 00000000..c5fc701a --- /dev/null +++ b/bitchat/Utils/MessageDeduplicator.swift @@ -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() + 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() + } + } +} + diff --git a/bitchat/Utils/PeerIDResolver.swift b/bitchat/Utils/PeerIDResolver.swift new file mode 100644 index 00000000..1411d59e --- /dev/null +++ b/bitchat/Utils/PeerIDResolver.swift @@ -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 + } +} + diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index b2f7d188..5016b238 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -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() // 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..