diff --git a/.gitignore b/.gitignore index fcb5d1d7..45e52c28 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ ## implementation plans plans/ +## AI +CLAUDE.md + ## User settings xcuserdata/ diff --git a/AI_CONTEXT.md b/AI_CONTEXT.md index 89728e6d..d8b82b55 100644 --- a/AI_CONTEXT.md +++ b/AI_CONTEXT.md @@ -47,14 +47,14 @@ BitChat is a decentralized, peer-to-peer messaging application that works over B │ ┌─────────────────────────────────────────────────────────────────┐ │ Bluetooth Transport Layer │ -│ (BluetoothMeshService) │ +│ (SimplifiedBluetoothService) │ └─────────────────────────────────────────────────────────────────┘ ``` ## Core Components -### 1. BluetoothMeshService (Transport Layer) -- **Location**: `bitchat/Services/BluetoothMeshService.swift` +### 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) @@ -159,7 +159,7 @@ BitChat is a decentralized, peer-to-peer messaging application that works over B ### Services (`/bitchat/Services/`) Application-level services that coordinate between layers: -- `BluetoothMeshService`: Core networking +- `SimplifiedBluetoothService`: Core networking - `NoiseEncryptionService`: Encryption coordination - `MessageRetryService`: Reliability layer - `DeliveryTracker`: Acknowledgment handling @@ -368,7 +368,7 @@ Structured content for different message types: 5. Update help text ### Debugging Bluetooth Issues -1. Check `BluetoothMeshService` logs +1. Check `SimplifiedBluetoothService` logs 2. Verify peer states and connections 3. Monitor characteristic updates 4. Use Bluetooth debugging tools diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..002fd08c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,204 @@ +# 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 c4683122..18ace6a0 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -7,101 +7,97 @@ objects = { /* Begin PBXBuildFile section */ - 0245710AEAA58AD0A1425234 /* OptimizedBloomFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB043CA5EEB9AC8B07D61E97 /* OptimizedBloomFilter.swift */; }; - 04636BBA2E2FAA1700FBCFA8 /* BinaryEncodingUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BB82E2FAA1700FBCFA8 /* BinaryEncodingUtils.swift */; }; - 04636BBC2E2FAA1700FBCFA8 /* BinaryEncodingUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BB82E2FAA1700FBCFA8 /* BinaryEncodingUtils.swift */; }; - 04636BBF2E2FCA8A00FBCFA8 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BBE2E2FCA8A00FBCFA8 /* SecureLogger.swift */; }; - 04636BC02E2FCA8A00FBCFA8 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BBE2E2FCA8A00FBCFA8 /* SecureLogger.swift */; }; - 04636BD12E30BE5100FBCFA8 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BCC2E30BE5100FBCFA8 /* BinaryProtocolTests.swift */; }; - 04636BD22E30BE5100FBCFA8 /* MockNoiseSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BC82E30BE5100FBCFA8 /* MockNoiseSession.swift */; }; - 04636BD32E30BE5100FBCFA8 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BC42E30BE5100FBCFA8 /* PrivateChatE2ETests.swift */; }; - 04636BD42E30BE5100FBCFA8 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BCE2E30BE5100FBCFA8 /* TestConstants.swift */; }; - 04636BD52E30BE5100FBCFA8 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BC72E30BE5100FBCFA8 /* MockBluetoothMeshService.swift */; }; - 04636BD62E30BE5100FBCFA8 /* NoiseProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BCA2E30BE5100FBCFA8 /* NoiseProtocolTests.swift */; }; - 04636BD72E30BE5100FBCFA8 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BCF2E30BE5100FBCFA8 /* TestHelpers.swift */; }; - 04636BD82E30BE5100FBCFA8 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BC52E30BE5100FBCFA8 /* PublicChatE2ETests.swift */; }; - 04636BD92E30BE5100FBCFA8 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BCC2E30BE5100FBCFA8 /* BinaryProtocolTests.swift */; }; - 04636BDA2E30BE5100FBCFA8 /* MockNoiseSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BC82E30BE5100FBCFA8 /* MockNoiseSession.swift */; }; - 04636BDB2E30BE5100FBCFA8 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BC42E30BE5100FBCFA8 /* PrivateChatE2ETests.swift */; }; - 04636BDC2E30BE5100FBCFA8 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BCE2E30BE5100FBCFA8 /* TestConstants.swift */; }; - 04636BDD2E30BE5100FBCFA8 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BC72E30BE5100FBCFA8 /* MockBluetoothMeshService.swift */; }; - 04636BDE2E30BE5100FBCFA8 /* NoiseProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BCA2E30BE5100FBCFA8 /* NoiseProtocolTests.swift */; }; - 04636BDF2E30BE5100FBCFA8 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BCF2E30BE5100FBCFA8 /* TestHelpers.swift */; }; - 04636BE02E30BE5100FBCFA8 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BC52E30BE5100FBCFA8 /* PublicChatE2ETests.swift */; }; - 04636BE82E30BEC600FBCFA8 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */; }; - 04636BEB2E30BEC600FBCFA8 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */; }; - 04636BED2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */; }; - 04636BEE2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */; }; - 046D705D2E3C105D00C00594 /* InputValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046D705C2E3C105D00C00594 /* InputValidator.swift */; }; - 046D705E2E3C105D00C00594 /* InputValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046D705C2E3C105D00C00594 /* InputValidator.swift */; }; - 04891CA92E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.swift */; }; - 04891CAA2E22971E0064A111 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04891CA82E22971E0064A111 /* LRUCache.swift */; }; - 04AD0B4E2E25B9580002A40A /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2446380E7A44E49A35B664 /* IdentityModels.swift */; }; - 04AD0B4F2E25B9580002A40A /* SecureIdentityStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EE9D4FA625C4671ACD371D4 /* SecureIdentityStateManager.swift */; }; - 04B6BA452E2035530090FE39 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */; }; - 04B6BA462E2035530090FE39 /* NoiseSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BA432E2035530090FE39 /* NoiseSession.swift */; }; - 04B6BA472E2035530090FE39 /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BA412E2035530090FE39 /* NoiseProtocol.swift */; }; - 04B6BA492E2035530090FE39 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */; }; - 04B6BA4A2E2035530090FE39 /* NoiseSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BA432E2035530090FE39 /* NoiseSession.swift */; }; - 04B6BA4B2E2035530090FE39 /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BA412E2035530090FE39 /* NoiseProtocol.swift */; }; - 04B6BA4E2E2038A70090FE39 /* NoiseEncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BA4D2E2038A70090FE39 /* NoiseEncryptionService.swift */; }; - 04B6BA4F2E2038A70090FE39 /* NoiseEncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BA4D2E2038A70090FE39 /* NoiseEncryptionService.swift */; }; - 04B6BA552E203D6C0090FE39 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BA532E203D6C0090FE39 /* FingerprintView.swift */; }; - 04B6BA572E203D6C0090FE39 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BA532E203D6C0090FE39 /* FingerprintView.swift */; }; - 04E363362E3800310048E624 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 04F127E72E37EBCD00FFBA8D /* P256K */; }; - 04E363372E3800310048E624 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 04F127E82E37EBDB00FFBA8D /* P256K */; }; - 04F127E42E37EBAA00FFBA8D /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127E22E37EBAA00FFBA8D /* NostrIdentity.swift */; }; - 04F127E52E37EBAA00FFBA8D /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127E22E37EBAA00FFBA8D /* NostrIdentity.swift */; }; - 04F127F22E37EEB800FFBA8D /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F02E37EEB800FFBA8D /* NostrProtocol.swift */; }; - 04F127F32E37EEB800FFBA8D /* NostrRelayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F12E37EEB800FFBA8D /* NostrRelayManager.swift */; }; - 04F127F42E37EEB800FFBA8D /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F02E37EEB800FFBA8D /* NostrProtocol.swift */; }; - 04F127F52E37EEB800FFBA8D /* NostrRelayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F12E37EEB800FFBA8D /* NostrRelayManager.swift */; }; - 04F127F82E37EEEE00FFBA8D /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F72E37EEEE00FFBA8D /* MessageRouter.swift */; }; - 04F127F92E37EEEE00FFBA8D /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F62E37EEEE00FFBA8D /* FavoritesPersistenceService.swift */; }; - 04F127FA2E37EEEE00FFBA8D /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F72E37EEEE00FFBA8D /* MessageRouter.swift */; }; - 04F127FB2E37EEEE00FFBA8D /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127F62E37EEEE00FFBA8D /* FavoritesPersistenceService.swift */; }; - 04F127FE2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127FC2E37EF3D00FFBA8D /* BitchatPeer.swift */; }; - 04F127FF2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127FC2E37EF3D00FFBA8D /* BitchatPeer.swift */; }; - 04F128042E37F00000FFBA8D /* ProcessedMessagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127FF2E37F00000FFBA8D /* ProcessedMessagesService.swift */; }; - 04F128052E37F00000FFBA8D /* ProcessedMessagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F127FF2E37F00000FFBA8D /* ProcessedMessagesService.swift */; }; - 04F128062E37F10000FFBA8D /* PeerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F128072E37F10000FFBA8D /* PeerSession.swift */; }; - 04F128082E37F10000FFBA8D /* PeerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F128072E37F10000FFBA8D /* PeerSession.swift */; }; - 0FBC81FF78CF4711B78E092A /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2446380E7A44E49A35B664 /* IdentityModels.swift */; }; + 049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; }; + 049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; }; + 049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; }; + 049BD3942E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; }; + 049BD3952E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; }; + 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 */; }; + 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 */; }; + 132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */; }; 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 1AF9F9036DEE42408D557A87 /* SecureIdentityStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EE9D4FA625C4671ACD371D4 /* SecureIdentityStateManager.swift */; }; 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; }; - 1F48A8CEEE9399D1EBD08F0C /* OptimizedBloomFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB043CA5EEB9AC8B07D61E97 /* OptimizedBloomFilter.swift */; }; + 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 */; }; + 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 */; }; - 5D95F2BFBE257A1225998389 /* BatteryOptimizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED176FF3B274E35C2D827894 /* BatteryOptimizer.swift */; }; + 501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 394E8A1AC76EFAE352075BE9 /* NoiseEncryptionService.swift */; }; + 5C93B4FDD0C448C3EDDBF8AE /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; }; + 5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 394E8A1AC76EFAE352075BE9 /* NoiseEncryptionService.swift */; }; 61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; }; + 686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC5AB43F4A8FB62C935CD74 /* IntegrationTests.swift */; }; + 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 */; }; + 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 */; }; + 7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */; }; 749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; }; 7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; }; - 7A50E2F04A3515A7E90EEAE4 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; }; + 765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */; }; 7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */; }; 7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F149C43D1915831B60FE09 /* CompressionUtil.swift */; }; 7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; }; - 8DCEEA289EF7C49E7CD38B08 /* DeliveryTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */; }; + 84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11186E29A064E8D210880E1B /* BitchatPeer.swift */; }; + 84E3F9B64FB7FB4A140BD0A8 /* BitchatPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11186E29A064E8D210880E1B /* BitchatPeer.swift */; }; + 8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */; }; + 885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; }; + 8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */; }; + 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 */; }; + 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 */; }; + 92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */; }; 92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; }; + 968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */; }; + 9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5318B743C64628A125261163 /* BinaryEncodingUtils.swift */; }; 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 */; }; + 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 */; }; + ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; }; AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; }; + AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5318B743C64628A125261163 /* BinaryEncodingUtils.swift */; }; + AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */; }; B0CA7796B2B2AC2B33F84548 /* CompressionUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F149C43D1915831B60FE09 /* CompressionUtil.swift */; }; + B45AD5BF95220A0289216D32 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */; }; + B909706CD38FC56C0C8EB7BF /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */; }; + BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */; }; BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; }; - C0A80BA73EC1A372B9338E3C /* BatteryOptimizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED176FF3B274E35C2D827894 /* BatteryOptimizer.swift */; }; - C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */; }; - CD0AE423F03AC52BAFC16834 /* DeliveryTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */; }; - CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */; }; + 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 */; }; + 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 */; }; - D948085736ED8E736C1DE3B0 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; }; + D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; }; + D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */; }; + D782AB596DDB5C846554F7C3 /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */; }; + E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */; }; E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */; }; + EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; }; + ED83C7AC1E6BEF15389C0132 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; }; + EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */; }; + EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */; }; + F06732B1719EE13C5D09CE77 /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; }; F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; }; + FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -144,169 +140,87 @@ /* Begin PBXFileReference section */ 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 04636BB82E2FAA1700FBCFA8 /* BinaryEncodingUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryEncodingUtils.swift; sourceTree = ""; }; - 04636BBE2E2FCA8A00FBCFA8 /* SecureLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureLogger.swift; sourceTree = ""; }; - 04636BC42E30BE5100FBCFA8 /* PrivateChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatE2ETests.swift; sourceTree = ""; }; - 04636BC52E30BE5100FBCFA8 /* PublicChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicChatE2ETests.swift; sourceTree = ""; }; - 04636BC72E30BE5100FBCFA8 /* MockBluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBluetoothMeshService.swift; sourceTree = ""; }; - 04636BC82E30BE5100FBCFA8 /* MockNoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNoiseSession.swift; sourceTree = ""; }; - 04636BCA2E30BE5100FBCFA8 /* NoiseProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocolTests.swift; sourceTree = ""; }; - 04636BCC2E30BE5100FBCFA8 /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = ""; }; - 04636BCE2E30BE5100FBCFA8 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = ""; }; - 04636BCF2E30BE5100FBCFA8 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = ""; }; - 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = ""; }; - 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = ""; }; - 046D705C2E3C105D00C00594 /* InputValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputValidator.swift; sourceTree = ""; }; - 04891CA82E22971E0064A111 /* LRUCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; - 04B6BA412E2035530090FE39 /* NoiseProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocol.swift; sourceTree = ""; }; - 04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSecurityConsiderations.swift; sourceTree = ""; }; - 04B6BA432E2035530090FE39 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = ""; }; - 04B6BA4D2E2038A70090FE39 /* NoiseEncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseEncryptionService.swift; sourceTree = ""; }; - 04B6BA532E203D6C0090FE39 /* FingerprintView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FingerprintView.swift; sourceTree = ""; }; - 04F127E22E37EBAA00FFBA8D /* NostrIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrIdentity.swift; sourceTree = ""; }; - 04F127F02E37EEB800FFBA8D /* NostrProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrProtocol.swift; sourceTree = ""; }; - 04F127F12E37EEB800FFBA8D /* NostrRelayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrRelayManager.swift; sourceTree = ""; }; - 04F127F62E37EEEE00FFBA8D /* FavoritesPersistenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesPersistenceService.swift; sourceTree = ""; }; - 04F127F72E37EEEE00FFBA8D /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = ""; }; - 04F127FC2E37EF3D00FFBA8D /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = ""; }; - 04F127FF2E37F00000FFBA8D /* ProcessedMessagesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessedMessagesService.swift; sourceTree = ""; }; - 04F128072E37F10000FFBA8D /* PeerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerSession.swift; sourceTree = ""; }; - 12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryTracker.swift; sourceTree = ""; }; + 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; 136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = ""; }; - 2EE9D4FA625C4671ACD371D4 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = ""; }; + 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = ""; }; + 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrProtocol.swift; sourceTree = ""; }; 32F149C43D1915831B60FE09 /* CompressionUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompressionUtil.swift; sourceTree = ""; }; 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 3668EEBB42FD4A24D5D83B7B /* bitchatShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = bitchatShareExtension.entitlements; sourceTree = ""; }; + 394E8A1AC76EFAE352075BE9 /* NoiseEncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseEncryptionService.swift; sourceTree = ""; }; 3A556661F74B7D5AE2F0521B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesPersistenceService.swift; sourceTree = ""; }; + 43613045E63D21D429396805 /* NoiseProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocol.swift; sourceTree = ""; }; + 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSecurityConsiderations.swift; sourceTree = ""; }; 527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = bitchat.entitlements; sourceTree = ""; }; + 5318B743C64628A125261163 /* BinaryEncodingUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryEncodingUtils.swift; sourceTree = ""; }; + 5BC5AB43F4A8FB62C935CD74 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = ""; }; + 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrIdentity.swift; sourceTree = ""; }; 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - 6E2446380E7A44E49A35B664 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; + 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 = ""; }; A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = ""; }; - AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetryService.swift; sourceTree = ""; }; + B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = ""; }; C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; - CB043CA5EEB9AC8B07D61E97 /* OptimizedBloomFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizedBloomFilter.swift; sourceTree = ""; }; - D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothMeshService.swift; sourceTree = ""; }; + C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrProtocolTests.swift; sourceTree = ""; }; + C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBluetoothMeshService.swift; sourceTree = ""; }; + D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicChatE2ETests.swift; sourceTree = ""; }; D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = ""; }; + E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocolTests.swift; sourceTree = ""; }; EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - ED176FF3B274E35C2D827894 /* BatteryOptimizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryOptimizer.swift; sourceTree = ""; }; + EE7EFB209C86BBD956B749EC /* SecureLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureLogger.swift; sourceTree = ""; }; 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 = ""; }; FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 04F127E92E37EBEF00FFBA8D /* Frameworks */ = { + 31F6FDADA63050361C14F3A1 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 04E363362E3800310048E624 /* P256K in Frameworks */, + 3EE336D150427F736F32B56C /* P256K in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - 04F127ED2E37EBFF00FFBA8D /* Frameworks */ = { + B5A5CC493FFB3D8966548140 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 04E363372E3800310048E624 /* P256K in Frameworks */, + 885BBED78092484A5B069461 /* P256K in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 04636BC62E30BE5100FBCFA8 /* EndToEnd */ = { + 0575DCBD15C7C719ADDCB67E /* Models */ = { isa = PBXGroup; children = ( - 04636BC42E30BE5100FBCFA8 /* PrivateChatE2ETests.swift */, - 04636BC52E30BE5100FBCFA8 /* PublicChatE2ETests.swift */, - ); - path = EndToEnd; - sourceTree = ""; - }; - 04636BC92E30BE5100FBCFA8 /* Mocks */ = { - isa = PBXGroup; - children = ( - 04636BC72E30BE5100FBCFA8 /* MockBluetoothMeshService.swift */, - 04636BC82E30BE5100FBCFA8 /* MockNoiseSession.swift */, - ); - path = Mocks; - sourceTree = ""; - }; - 04636BCB2E30BE5100FBCFA8 /* Noise */ = { - isa = PBXGroup; - children = ( - 04636BCA2E30BE5100FBCFA8 /* NoiseProtocolTests.swift */, - ); - path = Noise; - sourceTree = ""; - }; - 04636BCD2E30BE5100FBCFA8 /* Protocol */ = { - isa = PBXGroup; - children = ( - 04636BCC2E30BE5100FBCFA8 /* BinaryProtocolTests.swift */, - ); - path = Protocol; - sourceTree = ""; - }; - 04636BD02E30BE5100FBCFA8 /* TestUtilities */ = { - isa = PBXGroup; - children = ( - 04636BCE2E30BE5100FBCFA8 /* TestConstants.swift */, - 04636BCF2E30BE5100FBCFA8 /* TestHelpers.swift */, - ); - path = TestUtilities; - sourceTree = ""; - }; - 04636BE22E30BEC600FBCFA8 /* Integration */ = { - isa = PBXGroup; - children = ( - 04636BE12E30BEC600FBCFA8 /* IntegrationTests.swift */, - ); - path = Integration; - sourceTree = ""; - }; - 04B6BA442E2035530090FE39 /* Noise */ = { - isa = PBXGroup; - children = ( - 04636BEC2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift */, - 04B6BA412E2035530090FE39 /* NoiseProtocol.swift */, - 04B6BA422E2035530090FE39 /* NoiseSecurityConsiderations.swift */, - 04B6BA432E2035530090FE39 /* NoiseSession.swift */, - ); - path = Noise; - sourceTree = ""; - }; - 04F127E32E37EBAA00FFBA8D /* Nostr */ = { - isa = PBXGroup; - children = ( - 04F127F02E37EEB800FFBA8D /* NostrProtocol.swift */, - 04F127F12E37EEB800FFBA8D /* NostrRelayManager.swift */, - 04F127E22E37EBAA00FFBA8D /* NostrIdentity.swift */, - ); - path = Nostr; - sourceTree = ""; - }; - 04F127EA2E37EBF300FFBA8D /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; - 04F127FD2E37EF3D00FFBA8D /* Models */ = { - isa = PBXGroup; - children = ( - 04F127FC2E37EF3D00FFBA8D /* BitchatPeer.swift */, - 04F128072E37F10000FFBA8D /* PeerSession.swift */, + 11186E29A064E8D210880E1B /* BitchatPeer.swift */, ); path = Models; sourceTree = ""; @@ -318,10 +232,18 @@ A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */, C3D98EB3E1B455E321F519F4 /* bitchatTests */, 9F37F9F2C353B58AC809E93B /* Products */, - 04F127EA2E37EBF300FFBA8D /* Frameworks */, ); sourceTree = ""; }; + 204CC4C7704C7348D456E374 /* TestUtilities */ = { + isa = PBXGroup; + children = ( + FC75901A0F0073B5BB8356E7 /* TestConstants.swift */, + 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */, + ); + path = TestUtilities; + sourceTree = ""; + }; 2F82C5FC8433F4064F079D1F /* bitchat */ = { isa = PBXGroup; children = ( @@ -331,12 +253,12 @@ EF625BB3AD919322C01A46B2 /* BitchatApp.swift */, EA706D8E5097785414646A8E /* Info.plist */, 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */, + C845B6F5D25AEEA0B9FE557F /* Identity */, + 0575DCBD15C7C719ADDCB67E /* Models */, + 637EDFDD042BDB5F2569A501 /* Noise */, + E78C7F4B6769C0A72F5DE544 /* Nostr */, ADD53BCDA233C02E53458926 /* Protocols */, - 04B6BA442E2035530090FE39 /* Noise */, - 04F127E32E37EBAA00FFBA8D /* Nostr */, D98A3186D7E4C72E35BDF7FE /* Services */, - 6078981E5A3646BC84CC6DB4 /* Identity */, - 04F127FD2E37EF3D00FFBA8D /* Models */, 9A78348821A7D3374607D4E3 /* Utils */, 45BB7D87CAE42A8C0447D909 /* ViewModels */, A55126E93155456CAA8D6656 /* Views */, @@ -352,24 +274,48 @@ path = ViewModels; sourceTree = ""; }; - 6078981E5A3646BC84CC6DB4 /* Identity */ = { + 5B90895AFF0957E08FA3D429 /* Integration */ = { isa = PBXGroup; children = ( - 6E2446380E7A44E49A35B664 /* IdentityModels.swift */, - 2EE9D4FA625C4671ACD371D4 /* SecureIdentityStateManager.swift */, + 5BC5AB43F4A8FB62C935CD74 /* IntegrationTests.swift */, ); - path = Identity; + path = Integration; + sourceTree = ""; + }; + 637EDFDD042BDB5F2569A501 /* Noise */ = { + isa = PBXGroup; + children = ( + B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */, + 43613045E63D21D429396805 /* NoiseProtocol.swift */, + 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */, + 9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */, + ); + path = Noise; + sourceTree = ""; + }; + 84933DAE9D7E5D0155BA7AEA /* Protocol */ = { + isa = PBXGroup; + children = ( + 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */, + ); + path = Protocol; + sourceTree = ""; + }; + 966CD21F221332CF564AC724 /* Mocks */ = { + isa = PBXGroup; + children = ( + C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */, + FE7CCF2BD78A3F3DAE6DA145 /* MockSimplifiedBluetoothService.swift */, + ); + path = Mocks; sourceTree = ""; }; 9A78348821A7D3374607D4E3 /* Utils */ = { isa = PBXGroup; children = ( - 046D705C2E3C105D00C00594 /* InputValidator.swift */, - 04636BBE2E2FCA8A00FBCFA8 /* SecureLogger.swift */, - 04891CA82E22971E0064A111 /* LRUCache.swift */, - ED176FF3B274E35C2D827894 /* BatteryOptimizer.swift */, 32F149C43D1915831B60FE09 /* CompressionUtil.swift */, - CB043CA5EEB9AC8B07D61E97 /* OptimizedBloomFilter.swift */, + 90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */, + EE7EFB209C86BBD956B749EC /* SecureLogger.swift */, ); path = Utils; sourceTree = ""; @@ -399,9 +345,9 @@ A55126E93155456CAA8D6656 /* Views */ = { isa = PBXGroup; children = ( - 04B6BA532E203D6C0090FE39 /* FingerprintView.swift */, 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */, A08E03AA0C63E97C91749AEC /* ContentView.swift */, + 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */, 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */, ); path = Views; @@ -410,43 +356,81 @@ ADD53BCDA233C02E53458926 /* Protocols */ = { isa = PBXGroup; children = ( - 04636BB82E2FAA1700FBCFA8 /* BinaryEncodingUtils.swift */, + 5318B743C64628A125261163 /* BinaryEncodingUtils.swift */, A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */, 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */, ); path = Protocols; sourceTree = ""; }; + C2F78AB254FDAD5FEDA18B58 /* EndToEnd */ = { + isa = PBXGroup; + children = ( + 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */, + D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */, + ); + path = EndToEnd; + sourceTree = ""; + }; C3D98EB3E1B455E321F519F4 /* bitchatTests */ = { isa = PBXGroup; children = ( - 04636BE22E30BEC600FBCFA8 /* Integration */, - 04636BC62E30BE5100FBCFA8 /* EndToEnd */, - 04636BC92E30BE5100FBCFA8 /* Mocks */, - 04636BCB2E30BE5100FBCFA8 /* Noise */, - 04636BCD2E30BE5100FBCFA8 /* Protocol */, - 04636BD02E30BE5100FBCFA8 /* TestUtilities */, D69A18D27F9A565FD6041E12 /* Info.plist */, + C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */, + 980B109CBA72BC996455C62B /* SimplifiedBluetoothServiceTests.swift */, + C2F78AB254FDAD5FEDA18B58 /* EndToEnd */, + 5B90895AFF0957E08FA3D429 /* Integration */, + 966CD21F221332CF564AC724 /* Mocks */, + D80E19E04513C0046D611574 /* Noise */, + 84933DAE9D7E5D0155BA7AEA /* Protocol */, + 204CC4C7704C7348D456E374 /* TestUtilities */, ); path = bitchatTests; sourceTree = ""; }; + C845B6F5D25AEEA0B9FE557F /* Identity */ = { + isa = PBXGroup; + children = ( + 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */, + FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */, + ); + path = Identity; + sourceTree = ""; + }; + D80E19E04513C0046D611574 /* Noise */ = { + isa = PBXGroup; + children = ( + E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */, + ); + path = Noise; + sourceTree = ""; + }; D98A3186D7E4C72E35BDF7FE /* Services */ = { isa = PBXGroup; children = ( - 04F127F62E37EEEE00FFBA8D /* FavoritesPersistenceService.swift */, - 04F127F72E37EEEE00FFBA8D /* MessageRouter.swift */, - 04F127FF2E37F00000FFBA8D /* ProcessedMessagesService.swift */, - 04B6BA4D2E2038A70090FE39 /* NoiseEncryptionService.swift */, - D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */, - 12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */, + 049BD3982E506A12001A566B /* UnifiedPeerService.swift */, + 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */, + 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */, + 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */, + 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */, 136696FC4436A02D98CE6A77 /* KeychainManager.swift */, - AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */, + 394E8A1AC76EFAE352075BE9 /* NoiseEncryptionService.swift */, 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */, + 8C6FDA03416FDB2157A0A8C7 /* SimplifiedBluetoothService.swift */, ); path = Services; sourceTree = ""; }; + E78C7F4B6769C0A72F5DE544 /* Nostr */ = { + isa = PBXGroup; + children = ( + 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */, + 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */, + 78595178957244CBDF7E79B6 /* NostrRelayManager.swift */, + ); + path = Nostr; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -456,7 +440,7 @@ buildPhases = ( 137ABE739BF20ACDDF8CC605 /* Sources */, 0214973A876129753D39EB47 /* Resources */, - 04F127ED2E37EBFF00FFBA8D /* Frameworks */, + 31F6FDADA63050361C14F3A1 /* Frameworks */, ); buildRules = ( ); @@ -464,7 +448,7 @@ ); name = bitchat_macOS; packageProductDependencies = ( - 04F127E82E37EBDB00FFBA8D /* P256K */, + B1D9136AA0083366353BFA2F /* P256K */, ); productName = bitchat_macOS; productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */; @@ -529,8 +513,8 @@ buildPhases = ( 4E49E34F00154C051AE90FED /* Sources */, CD6E8F32BC38357473954F97 /* Resources */, + B5A5CC493FFB3D8966548140 /* Frameworks */, B6C356449BAE4E0F650565D1 /* Embed Foundation Extensions */, - 04F127E92E37EBEF00FFBA8D /* Frameworks */, ); buildRules = ( ); @@ -539,7 +523,7 @@ ); name = bitchat_iOS; packageProductDependencies = ( - 04F127E72E37EBCD00FFBA8D /* P256K */, + 4EB6BA1B8464F1EA38F4E286 /* P256K */, ); productName = bitchat_iOS; productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */; @@ -587,7 +571,7 @@ mainGroup = 18198ED912AAF495D8AF7763; minimizedProjectReferenceProxies = 1; packageReferences = ( - 04F127E62E37EBCD00FFBA8D /* XCRemoteSwiftPackageReference "swift-secp256k1" */, + B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */, ); projectDirPath = ""; projectRoot = ""; @@ -635,40 +619,36 @@ buildActionMask = 2147483647; files = ( AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */, - C0A80BA73EC1A372B9338E3C /* BatteryOptimizer.swift in Sources */, + 9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */, 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */, 6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */, - 04891CAA2E22971E0064A111 /* LRUCache.swift in Sources */, + 84E3F9B64FB7FB4A140BD0A8 /* BitchatPeer.swift in Sources */, 923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */, - 04B6BA452E2035530090FE39 /* NoiseSecurityConsiderations.swift in Sources */, - 04B6BA462E2035530090FE39 /* NoiseSession.swift in Sources */, - 04B6BA472E2035530090FE39 /* NoiseProtocol.swift in Sources */, - 7A50E2F04A3515A7E90EEAE4 /* BluetoothMeshService.swift in Sources */, - 04F127F42E37EEB800FFBA8D /* NostrProtocol.swift in Sources */, - 04F127F52E37EEB800FFBA8D /* NostrRelayManager.swift in Sources */, D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */, B0CA7796B2B2AC2B33F84548 /* CompressionUtil.swift in Sources */, 92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */, - 04B6BA4F2E2038A70090FE39 /* NoiseEncryptionService.swift in Sources */, - 8DCEEA289EF7C49E7CD38B08 /* DeliveryTracker.swift in Sources */, + 5C93B4FDD0C448C3EDDBF8AE /* FavoritesPersistenceService.swift in Sources */, + 6C63FA98D59854C15C57B3D6 /* FingerprintView.swift in Sources */, + 38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */, + 7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */, FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */, - 04F127E52E37EBAA00FFBA8D /* NostrIdentity.swift in Sources */, - 04636BBA2E2FAA1700FBCFA8 /* BinaryEncodingUtils.swift in Sources */, - 04AD0B4E2E25B9580002A40A /* IdentityModels.swift in Sources */, - 04AD0B4F2E25B9580002A40A /* SecureIdentityStateManager.swift in Sources */, 31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */, - 04F127FE2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */, - 04F128062E37F10000FFBA8D /* PeerSession.swift in Sources */, - 046D705E2E3C105D00C00594 /* InputValidator.swift in Sources */, - 04B6BA572E203D6C0090FE39 /* FingerprintView.swift in Sources */, - 04636BED2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */, - C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */, + 501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */, + AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */, + 8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */, + D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */, + 8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */, + 049BD3992E506A12001A566B /* UnifiedPeerService.swift in Sources */, + C3B1226CD30C87501EF6F12F /* NostrIdentity.swift in Sources */, + FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */, + 6A85FC357ACD85DBD9020845 /* NostrRelayManager.swift in Sources */, 749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */, - 04636BBF2E2FCA8A00FBCFA8 /* SecureLogger.swift in Sources */, - 0245710AEAA58AD0A1425234 /* OptimizedBloomFilter.swift in Sources */, - 04F127F82E37EEEE00FFBA8D /* MessageRouter.swift in Sources */, - 04F127F92E37EEEE00FFBA8D /* FavoritesPersistenceService.swift in Sources */, - 04F128042E37F00000FFBA8D /* ProcessedMessagesService.swift in Sources */, + E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.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 */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -677,40 +657,36 @@ buildActionMask = 2147483647; files = ( ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */, - 5D95F2BFBE257A1225998389 /* BatteryOptimizer.swift in Sources */, + AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */, F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */, 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */, - 04891CA92E22971E0064A111 /* LRUCache.swift in Sources */, + 84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */, 6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */, - 04B6BA492E2035530090FE39 /* NoiseSecurityConsiderations.swift in Sources */, - 04B6BA4A2E2035530090FE39 /* NoiseSession.swift in Sources */, - 04B6BA4B2E2035530090FE39 /* NoiseProtocol.swift in Sources */, - D948085736ED8E736C1DE3B0 /* BluetoothMeshService.swift in Sources */, - 04F127F22E37EEB800FFBA8D /* NostrProtocol.swift in Sources */, - 04F127F32E37EEB800FFBA8D /* NostrRelayManager.swift in Sources */, 7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */, 7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */, 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */, - 04B6BA4E2E2038A70090FE39 /* NoiseEncryptionService.swift in Sources */, - CD0AE423F03AC52BAFC16834 /* DeliveryTracker.swift in Sources */, + ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */, + 132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */, + B909706CD38FC56C0C8EB7BF /* IdentityModels.swift in Sources */, + EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */, 8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */, - 04F127E42E37EBAA00FFBA8D /* NostrIdentity.swift in Sources */, - 04636BBC2E2FAA1700FBCFA8 /* BinaryEncodingUtils.swift in Sources */, - 0FBC81FF78CF4711B78E092A /* IdentityModels.swift in Sources */, - 1AF9F9036DEE42408D557A87 /* SecureIdentityStateManager.swift in Sources */, 7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */, - 04F127FF2E37EF3D00FFBA8D /* BitchatPeer.swift in Sources */, - 04F128082E37F10000FFBA8D /* PeerSession.swift in Sources */, - 046D705D2E3C105D00C00594 /* InputValidator.swift in Sources */, - 04B6BA552E203D6C0090FE39 /* FingerprintView.swift in Sources */, - 04636BEE2E30CD4A00FBCFA8 /* NoiseHandshakeCoordinator.swift in Sources */, - CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */, + 5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */, + 6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */, + A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */, + 9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */, + 92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */, + 049BD39A2E506A12001A566B /* UnifiedPeerService.swift in Sources */, + D782AB596DDB5C846554F7C3 /* NostrIdentity.swift in Sources */, + F06732B1719EE13C5D09CE77 /* NostrProtocol.swift in Sources */, + BCD0EBACD82AF5E55C2CB2B9 /* NostrRelayManager.swift in Sources */, 61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */, - 04636BC02E2FCA8A00FBCFA8 /* SecureLogger.swift in Sources */, - 1F48A8CEEE9399D1EBD08F0C /* OptimizedBloomFilter.swift in Sources */, - 04F127FA2E37EEEE00FFBA8D /* MessageRouter.swift in Sources */, - 04F127FB2E37EEEE00FFBA8D /* FavoritesPersistenceService.swift in Sources */, - 04F128052E37F00000FFBA8D /* ProcessedMessagesService.swift in Sources */, + 68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.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 */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -718,15 +694,17 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 04636BD92E30BE5100FBCFA8 /* BinaryProtocolTests.swift in Sources */, - 04636BE82E30BEC600FBCFA8 /* IntegrationTests.swift in Sources */, - 04636BDA2E30BE5100FBCFA8 /* MockNoiseSession.swift in Sources */, - 04636BDB2E30BE5100FBCFA8 /* PrivateChatE2ETests.swift in Sources */, - 04636BDC2E30BE5100FBCFA8 /* TestConstants.swift in Sources */, - 04636BDD2E30BE5100FBCFA8 /* MockBluetoothMeshService.swift in Sources */, - 04636BDE2E30BE5100FBCFA8 /* NoiseProtocolTests.swift in Sources */, - 04636BDF2E30BE5100FBCFA8 /* TestHelpers.swift in Sources */, - 04636BE02E30BE5100FBCFA8 /* PublicChatE2ETests.swift in Sources */, + AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */, + 8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */, + D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */, + 6C803BF930E7E19BE6E99EAA /* MockSimplifiedBluetoothService.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 */, + 2EFCCAA297B16FA2B56747C7 /* TestConstants.swift in Sources */, + B45AD5BF95220A0289216D32 /* TestHelpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -734,15 +712,17 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 04636BD12E30BE5100FBCFA8 /* BinaryProtocolTests.swift in Sources */, - 04636BEB2E30BEC600FBCFA8 /* IntegrationTests.swift in Sources */, - 04636BD22E30BE5100FBCFA8 /* MockNoiseSession.swift in Sources */, - 04636BD32E30BE5100FBCFA8 /* PrivateChatE2ETests.swift in Sources */, - 04636BD42E30BE5100FBCFA8 /* TestConstants.swift in Sources */, - 04636BD52E30BE5100FBCFA8 /* MockBluetoothMeshService.swift in Sources */, - 04636BD62E30BE5100FBCFA8 /* NoiseProtocolTests.swift in Sources */, - 04636BD72E30BE5100FBCFA8 /* TestHelpers.swift in Sources */, - 04636BD82E30BE5100FBCFA8 /* PublicChatE2ETests.swift in Sources */, + 0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */, + 686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */, + 8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */, + 3849CA6D99B2D536636DF4A6 /* MockSimplifiedBluetoothService.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 */, + 8D0196EAEE56973679F6A655 /* TestConstants.swift in Sources */, + 37DDF3D09E2BAB92A5A8A9C1 /* TestHelpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -842,7 +822,6 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; INFOPLIST_FILE = bitchatShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -873,7 +852,6 @@ CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -929,7 +907,6 @@ CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -962,12 +939,10 @@ CODE_SIGN_ENTITLEMENTS = "bitchat/bitchat-macOS.entitlements"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -986,7 +961,6 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -1054,12 +1028,10 @@ CODE_SIGN_ENTITLEMENTS = "bitchat/bitchat-macOS.entitlements"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -1078,7 +1050,6 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -1151,7 +1122,6 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; INFOPLIST_FILE = bitchatShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -1232,7 +1202,7 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 04F127E62E37EBCD00FFBA8D /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { + B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1"; requirement = { @@ -1243,14 +1213,14 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 04F127E72E37EBCD00FFBA8D /* P256K */ = { + 4EB6BA1B8464F1EA38F4E286 /* P256K */ = { isa = XCSwiftPackageProductDependency; - package = 04F127E62E37EBCD00FFBA8D /* XCRemoteSwiftPackageReference "swift-secp256k1" */; + package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; productName = P256K; }; - 04F127E82E37EBDB00FFBA8D /* P256K */ = { + B1D9136AA0083366353BFA2F /* P256K */ = { isa = XCSwiftPackageProductDependency; - package = 04F127E62E37EBCD00FFBA8D /* XCRemoteSwiftPackageReference "swift-secp256k1" */; + package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; productName = P256K; }; /* End XCSwiftPackageProductDependency section */ diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 410f724d..7a1af865 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -13,6 +13,7 @@ import UserNotifications struct BitchatApp: App { @StateObject private var chatViewModel = ChatViewModel() #if os(iOS) + @Environment(\.scenePhase) var scenePhase @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate #elseif os(macOS) @NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate @@ -40,16 +41,28 @@ struct BitchatApp: App { handleURL(url) } #if os(iOS) + .onChange(of: scenePhase) { newPhase in + switch newPhase { + case .background: + // Send leave message when going to background + chatViewModel.meshService.stopServices() + case .active: + // Restart services when becoming active + chatViewModel.meshService.startServices() + checkForSharedContent() + case .inactive: + break + @unknown default: + break + } + } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in // Check for shared content when app becomes active checkForSharedContent() - // Notify MessageRouter to check for Nostr messages - NotificationCenter.default.post(name: .appDidBecomeActive, object: nil) } #elseif os(macOS) .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - // Notify MessageRouter to check for Nostr messages - NotificationCenter.default.post(name: .appDidBecomeActive, object: nil) + // App became active } #endif } diff --git a/bitchat/Info.plist b/bitchat/Info.plist index 37b0d941..dfd79024 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -39,33 +39,14 @@ bluetooth-central bluetooth-peripheral - remote-notification UILaunchStoryboardName LaunchScreen + UIRequiresFullScreen + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsArbitraryLoadsForMedia - - NSAllowsArbitraryLoadsInWebContent - - NSAllowsLocalNetworking - - - NSLocalNetworkUsageDescription - bitchat uses local network access to discover and connect with other bitchat users on your network. diff --git a/bitchat/LaunchScreen.storyboard b/bitchat/LaunchScreen.storyboard index 6e4c7260..7d2b247a 100644 --- a/bitchat/LaunchScreen.storyboard +++ b/bitchat/LaunchScreen.storyboard @@ -37,4 +37,4 @@ - \ No newline at end of file + diff --git a/bitchat/Models/BitchatPeer.swift b/bitchat/Models/BitchatPeer.swift index 566bc734..8dd1ab44 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: BluetoothMeshService + private let meshService: SimplifiedBluetoothService private let favoritesService = FavoritesPersistenceService.shared - init(meshService: BluetoothMeshService) { + init(meshService: SimplifiedBluetoothService) { self.meshService = meshService updatePeers() @@ -142,8 +142,6 @@ class PeerManager: ObservableObject { // Safety check: Never add our own peer ID if peerID == meshService.myPeerID { - SecureLogger.log("⚠️ Skipping self peer ID \(peerID) in peer list", - category: SecureLogger.session, level: .warning) continue } @@ -195,22 +193,19 @@ class PeerManager: ObservableObject { // Skip if this peer is already connected (by nickname) if connectedNicknames.contains(favorite.peerNickname) { - SecureLogger.log(" - Skipping '\(favorite.peerNickname)' (key: \(favoriteKey.hexEncodedString())) - already connected", - category: SecureLogger.session, level: .debug) + // Skipping favorite - already connected continue } // Skip if we already added a peer with this ID (prevents duplicates) if addedPeerIDs.contains(favoriteID) { - SecureLogger.log(" - Skipping '\(favorite.peerNickname)' - peer ID already added", - category: SecureLogger.session, level: .debug) + // Skipping favorite - peer ID already added continue } // Only add peers that WE favorite (not just ones who favorite us) if !favorite.isFavorite { - SecureLogger.log(" - Skipping '\(favorite.peerNickname)' - we don't favorite them (they favorite us: \(favorite.theyFavoritedUs))", - category: SecureLogger.session, level: .debug) + // Skipping - we don't favorite them continue } @@ -292,21 +287,7 @@ class PeerManager: ObservableObject { SecureLogger.log("📊 Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual", category: SecureLogger.session, level: .info) - // Log each peer's status - for peer in allPeers { - let statusIcon: String - switch peer.connectionState { - case .bluetoothConnected: - statusIcon = "🟢" - case .nostrAvailable: - statusIcon = "🌐" - case .offline: - statusIcon = "🔴" - } - let favoriteIcon = peer.isMutualFavorite ? "💕" : (peer.isFavorite ? "⭐" : (peer.theyFavoritedUs ? "🌙" : "")) - SecureLogger.log(" \(statusIcon) \(peer.displayName) (ID: \(peer.id.prefix(8))...) \(favoriteIcon)", - category: SecureLogger.session, level: .debug) - } + // Detailed peer status logging removed for brevity } else if previousCount != allPeers.count { // Only log non-favorite updates if count changed SecureLogger.log("✅ Updated peer list: \(allPeers.count) total peers", diff --git a/bitchat/Models/PeerSession.swift b/bitchat/Models/PeerSession.swift deleted file mode 100644 index 76982000..00000000 --- a/bitchat/Models/PeerSession.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Foundation -import CoreBluetooth - -/// Unified model for tracking all peer session data -/// Consolidates multiple redundant data structures into a single source of truth -class PeerSession { - // Core identification - let peerID: String - var nickname: String - - // Bluetooth connection - var peripheral: CBPeripheral? - var peripheralID: String? - var characteristic: CBCharacteristic? - - // Authentication and encryption - var isAuthenticated: Bool = false - var hasEstablishedNoiseSession: Bool = false - var fingerprint: String? - - // Connection state - var isConnected: Bool = false - var lastSeen: Date - - // Protocol state - var hasAnnounced: Bool = false - var hasReceivedAnnounce: Bool = false - var isActivePeer: Bool = false - - // Message tracking - var lastMessageSent: Date? - var lastMessageReceived: Date? - var pendingMessages: [String] = [] - - // Connection timing - var lastConnectionTime: Date? - var lastSuccessfulMessageTime: Date? - var lastHeardFromPeer: Date? - - // Availability tracking - var isAvailable: Bool = false - - // Identity binding - var identityBinding: PeerIdentityBinding? - - init(peerID: String, nickname: String = "Unknown") { - self.peerID = peerID - self.nickname = nickname - self.lastSeen = Date() - } - - /// Update Bluetooth connection info - func updateBluetoothConnection(peripheral: CBPeripheral?, characteristic: CBCharacteristic?) { - self.peripheral = peripheral - self.peripheralID = peripheral?.identifier.uuidString - self.characteristic = characteristic - self.isConnected = (peripheral?.state == .connected) - if isConnected { - self.lastSeen = Date() - } - } - - /// Update authentication state - func updateAuthenticationState(authenticated: Bool, noiseSession: Bool) { - self.isAuthenticated = authenticated - self.hasEstablishedNoiseSession = noiseSession - if authenticated { - self.isActivePeer = true - } - } - - - /// Check if session is stale - var isStale: Bool { - // Consider stale if not seen for more than 5 minutes and not connected - return !isConnected && Date().timeIntervalSince(lastSeen) > 300 - } - - /// Get display status for UI - var displayStatus: String { - if isConnected { - if isAuthenticated { - return "🟢" // Connected and authenticated - } else { - return "🟡" // Connected but not authenticated - } - } else { - return "🔴" // Not connected - } - } -} \ No newline at end of file diff --git a/bitchat/Nostr/NostrIdentity.swift b/bitchat/Nostr/NostrIdentity.swift index c24ef0e0..86d9d1fd 100644 --- a/bitchat/Nostr/NostrIdentity.swift +++ b/bitchat/Nostr/NostrIdentity.swift @@ -140,6 +140,16 @@ struct NostrIdentityBridge { } return pubkey } + + /// Clear all Nostr identity associations and current identity + static func clearAllAssociations() { + // Delete current Nostr identity + KeychainHelper.delete(key: currentIdentityKey, service: keychainService) + + // Note: We can't efficiently delete all noise-nostr associations + // without tracking them, but they'll be orphaned and eventually cleaned up + // The important part is deleting the current identity so a new one is generated + } } // Bech32 encoding for Nostr (minimal implementation) diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 55880c5e..cddcbd55 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -59,10 +59,11 @@ struct NostrProtocol { } /// Decrypt a received NIP-17 message + /// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp) static func decryptPrivateMessage( giftWrap: NostrEvent, recipientIdentity: NostrIdentity - ) throws -> (content: String, senderPubkey: String) { + ) throws -> (content: String, senderPubkey: String, timestamp: Int) { // Starting decryption @@ -94,7 +95,7 @@ struct NostrProtocol { throw error } - return (content: rumor.content, senderPubkey: rumor.pubkey) + return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at) } // MARK: - Private Methods @@ -438,8 +439,9 @@ struct NostrProtocol { private static func randomizedTimestamp() -> Date { // Add random offset to current time for privacy - // TEMPORARY: Reduced range to debug timestamp issue - let offset = TimeInterval.random(in: -60...60) // +/- 1 minute (was +/- 15 minutes) + // This prevents timing correlation attacks while the actual message timestamp + // is preserved in the encrypted rumor + let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes let now = Date() let randomized = now.addingTimeInterval(offset) diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index fa7c673d..462185a1 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -57,6 +57,7 @@ class NostrRelayManager: ObservableObject { /// Connect to all configured relays func connect() { + SecureLogger.log("🌐 Connecting to \(relays.count) Nostr relays", category: SecureLogger.session, level: .debug) for relay in relays { connectToRelay(relay.url) } @@ -96,6 +97,9 @@ class NostrRelayManager: ObservableObject { ) { messageHandlers[id] = handler + SecureLogger.log("📡 Setting up subscription '\(id)' with filter - kinds: \(filter.kinds ?? []), since: \(filter.since ?? 0)", + category: SecureLogger.session, level: .debug) + let req = NostrRequest.subscribe(id: id, filters: [filter]) do { @@ -107,9 +111,8 @@ class NostrRelayManager: ObservableObject { return } - // Sending subscription to relays - // Filter JSON prepared - // Full filter JSON logged + // SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...", + // category: SecureLogger.session, level: .debug) // Send subscription to all connected relays for (relayUrl, connection) in connections { @@ -118,6 +121,8 @@ class NostrRelayManager: ObservableObject { SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)", category: SecureLogger.session, level: .error) } else { + // SecureLogger.log("✅ Subscription '\(id)' sent to relay: \(relayUrl)", + // category: SecureLogger.session, level: .debug) // Subscription sent successfully Task { @MainActor in var subs = self.subscriptions[relayUrl] ?? Set() @@ -188,10 +193,11 @@ class NostrRelayManager: ObservableObject { task.sendPing { [weak self] error in DispatchQueue.main.async { if error == nil { - // Successfully connected to Nostr relay + SecureLogger.log("✅ Connected to Nostr relay: \(urlString)", + category: SecureLogger.session, level: .debug) self?.updateRelayStatus(urlString, isConnected: true) } else { - SecureLogger.log("Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", + SecureLogger.log("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: SecureLogger.session, level: .error) self?.updateRelayStatus(urlString, isConnected: false, error: error) // Trigger disconnection handler for proper backoff @@ -254,7 +260,11 @@ class NostrRelayManager: ObservableObject { let event = try NostrEvent(from: eventDict) - // Processing event + // Only log critical events + if event.kind != 1059 { // Don't log every gift wrap + SecureLogger.log("📥 Received Nostr event (kind: \(event.kind)) from relay: \(relayUrl)", + category: SecureLogger.session, level: .debug) + } DispatchQueue.main.async { // Update relay stats @@ -282,7 +292,10 @@ class NostrRelayManager: ObservableObject { let eventId = array[1] as? String, let success = array[2] as? Bool { let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given" - if !success { + if success { + SecureLogger.log("✅ Event \(eventId.prefix(16))... accepted by relay: \(relayUrl)", + category: SecureLogger.session, level: .debug) + } else { SecureLogger.log("📮 Event \(eventId) rejected by relay: \(reason)", category: SecureLogger.session, level: .error) } @@ -311,8 +324,8 @@ class NostrRelayManager: ObservableObject { let data = try encoder.encode(req) let message = String(data: data, encoding: .utf8) ?? "" - // Sending event to relay - // Event JSON prepared + SecureLogger.log("📤 Sending Nostr event (kind: \(event.kind)) to relay: \(relayUrl)", + category: SecureLogger.session, level: .debug) connection.send(.string(message)) { [weak self] error in DispatchQueue.main.async { @@ -320,6 +333,8 @@ class NostrRelayManager: ObservableObject { SecureLogger.log("❌ Failed to send event to \(relayUrl): \(error)", category: SecureLogger.session, level: .error) } else { + // SecureLogger.log("✅ Event sent to relay: \(relayUrl)", + // category: SecureLogger.session, level: .debug) // Update relay stats if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) { self?.relays[index].messagesSent += 1 diff --git a/bitchat/Protocols/BinaryProtocol.swift b/bitchat/Protocols/BinaryProtocol.swift index 50ed85cd..ba677654 100644 --- a/bitchat/Protocols/BinaryProtocol.swift +++ b/bitchat/Protocols/BinaryProtocol.swift @@ -209,7 +209,7 @@ struct BinaryProtocol { // Decode binary data to BitchatPacket static func decode(_ data: Data) -> BitchatPacket? { - // Remove padding first + // Remove padding first and create a defensive copy let unpaddedData = MessagePadding.unpad(data) // Minimum size check: header + senderID @@ -217,41 +217,43 @@ struct BinaryProtocol { return nil } + // Convert to array for safer indexed access + let dataArray = Array(unpaddedData) var offset = 0 // Header parsing with bounds checks - guard offset + 1 <= unpaddedData.count else { return nil } - let version = unpaddedData[offset]; offset += 1 + guard offset < dataArray.count else { return nil } + let version = dataArray[offset]; offset += 1 // Check if version is 1 (only supported version) guard version == 1 else { return nil } - guard offset + 1 <= unpaddedData.count else { return nil } - let type = unpaddedData[offset]; offset += 1 + guard offset < dataArray.count else { return nil } + let type = dataArray[offset]; offset += 1 - guard offset + 1 <= unpaddedData.count else { return nil } - let ttl = unpaddedData[offset]; offset += 1 + guard offset < dataArray.count else { return nil } + let ttl = dataArray[offset]; offset += 1 // Timestamp - need 8 bytes - guard offset + 8 <= unpaddedData.count else { return nil } - let timestampData = unpaddedData[offset..= 2 else { return nil } // Check we have enough data for the original size prefix - guard offset + 2 <= unpaddedData.count else { return nil } - let originalSizeData = unpaddedData[offset..= 0 && offset + compressedPayloadSize <= unpaddedData.count else { + guard compressedPayloadSize >= 0 && offset + compressedPayloadSize <= dataArray.count else { return nil } - let compressedPayload = unpaddedData[offset..= 0 && offset + Int(payloadLength) <= unpaddedData.count else { + guard Int(payloadLength) >= 0 && offset + Int(payloadLength) <= dataArray.count else { return nil } - payload = unpaddedData[offset..= 0 else { return data } + + let paddingLength = Int(data[lastIndex]) guard paddingLength > 0 && paddingLength <= data.count else { - // Debug logging for 243-byte packets - if data.count == 243 { - } + // No valid padding, return original data return data } - let result = data.prefix(data.count - paddingLength) + // Create a new Data object (not a subsequence) for thread safety + let unpaddedLength = data.count - paddingLength + guard unpaddedLength >= 0 else { return Data() } - // Debug logging for 243-byte packets - if data.count == 243 { - } - - return result + // Return a proper copy, not a subsequence + return Data(data.prefix(unpaddedLength)) } // Find optimal block size for data @@ -132,57 +132,50 @@ struct MessagePadding { // MARK: - Message Types -/// Defines all message types in the BitChat protocol. -/// Each type has a unique identifier for efficient binary encoding. -/// Types are grouped by function: user messages, protocol control, encryption, etc. +/// Simplified BitChat protocol message types. +/// Reduced from 24 types to just 6 essential ones. +/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads. enum MessageType: UInt8 { - case announce = 0x01 - case leave = 0x03 - case message = 0x04 // All user messages (private and broadcast) - case fragmentStart = 0x05 - case fragmentContinue = 0x06 - case fragmentEnd = 0x07 - case deliveryAck = 0x0A // Acknowledge message received - case deliveryStatusRequest = 0x0B // Request delivery status update - case readReceipt = 0x0C // Message has been read/viewed + // Public messages (unencrypted) + case announce = 0x01 // "I'm here" with nickname + case message = 0x02 // Public chat message + case leave = 0x03 // "I'm leaving" - // Noise Protocol messages - case noiseHandshakeInit = 0x10 // Noise handshake initiation - case noiseHandshakeResp = 0x11 // Noise handshake response - case noiseEncrypted = 0x12 // Noise encrypted transport message - case noiseIdentityAnnounce = 0x13 // Announce static public key for discovery + // Noise encryption + case noiseHandshake = 0x10 // Handshake (init or response determined by payload) + case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.) - // Protocol-level acknowledgments - case protocolAck = 0x22 // Generic protocol acknowledgment - case protocolNack = 0x23 // Negative acknowledgment (failure) - case systemValidation = 0x24 // Session validation ping - case handshakeRequest = 0x25 // Request handshake for pending messages - - // Favorite system messages - case favorited = 0x30 // Peer favorited us - case unfavorited = 0x31 // Peer unfavorited us + // Fragmentation (simplified) + case fragment = 0x20 // Single fragment type for large messages var description: String { switch self { case .announce: return "announce" - case .leave: return "leave" case .message: return "message" - case .fragmentStart: return "fragmentStart" - case .fragmentContinue: return "fragmentContinue" - case .fragmentEnd: return "fragmentEnd" - case .deliveryAck: return "deliveryAck" - case .deliveryStatusRequest: return "deliveryStatusRequest" - case .readReceipt: return "readReceipt" - case .noiseHandshakeInit: return "noiseHandshakeInit" - case .noiseHandshakeResp: return "noiseHandshakeResp" + case .leave: return "leave" + case .noiseHandshake: return "noiseHandshake" case .noiseEncrypted: return "noiseEncrypted" - case .noiseIdentityAnnounce: return "noiseIdentityAnnounce" - case .protocolAck: return "protocolAck" - case .protocolNack: return "protocolNack" - case .systemValidation: return "systemValidation" - case .handshakeRequest: return "handshakeRequest" - case .favorited: return "favorited" - case .unfavorited: return "unfavorited" + case .fragment: return "fragment" + } + } +} + +// MARK: - Noise Payload Types + +/// Types of payloads embedded within noiseEncrypted messages. +/// The first byte of decrypted Noise payload indicates the type. +/// This provides privacy - observers can't distinguish message types. +enum NoisePayloadType: UInt8 { + // Messages and status + case privateMessage = 0x01 // Private chat message + case readReceipt = 0x02 // Message was read + case delivered = 0x03 // Message was delivered + + var description: String { + switch self { + case .privateMessage: return "privateMessage" + case .readReceipt: return "readReceipt" + case .delivered: return "delivered" } } } @@ -198,14 +191,8 @@ enum LazyHandshakeState { case failed(Error) // Handshake failed } -// MARK: - Special Recipients - -/// Defines special recipient identifiers used in the protocol. -/// These magic values indicate broadcast or system-level recipients -/// rather than specific peer IDs. -struct SpecialRecipients { - static let broadcast = Data(repeating: 0xFF, count: 8) // All 0xFF = broadcast -} +// MARK: - Special Recipients (removed) +// Previously defined broadcast identifiers were unused; removed for simplicity. // MARK: - Core Protocol Structures @@ -269,103 +256,8 @@ struct BitchatPacket: Codable { } } -// MARK: - Delivery Acknowledgments - -/// Acknowledgment sent when a message is successfully delivered to a recipient. -/// Provides delivery confirmation for reliable messaging and UI feedback. -/// - Note: Only sent for direct messages, not broadcasts -struct DeliveryAck: Codable { - let originalMessageID: String - let ackID: String - let recipientID: String // Who received it - let recipientNickname: String - let timestamp: Date - let hopCount: UInt8 // How many hops to reach recipient - - init(originalMessageID: String, recipientID: String, recipientNickname: String, hopCount: UInt8) { - self.originalMessageID = originalMessageID - self.ackID = UUID().uuidString - self.recipientID = recipientID - self.recipientNickname = recipientNickname - self.timestamp = Date() - self.hopCount = hopCount - } - - // For binary decoding - private init(originalMessageID: String, ackID: String, recipientID: String, recipientNickname: String, timestamp: Date, hopCount: UInt8) { - self.originalMessageID = originalMessageID - self.ackID = ackID - self.recipientID = recipientID - self.recipientNickname = recipientNickname - self.timestamp = timestamp - self.hopCount = hopCount - } - - func encode() -> Data? { - try? JSONEncoder().encode(self) - } - - static func decode(from data: Data) -> DeliveryAck? { - try? JSONDecoder().decode(DeliveryAck.self, from: data) - } - - // MARK: - Binary Encoding - - func toBinaryData() -> Data { - var data = Data() - data.appendUUID(originalMessageID) - data.appendUUID(ackID) - // RecipientID as 8-byte hex string - var recipientData = Data() - var tempID = recipientID - while tempID.count >= 2 && recipientData.count < 8 { - let hexByte = String(tempID.prefix(2)) - if let byte = UInt8(hexByte, radix: 16) { - recipientData.append(byte) - } - tempID = String(tempID.dropFirst(2)) - } - while recipientData.count < 8 { - recipientData.append(0) - } - data.append(recipientData) - data.appendUInt8(hopCount) - data.appendDate(timestamp) - data.appendString(recipientNickname) - return data - } - - static func fromBinaryData(_ data: Data) -> DeliveryAck? { - // Create defensive copy - let dataCopy = Data(data) - - // Minimum size: 2 UUIDs (32) + recipientID (8) + hopCount (1) + timestamp (8) + min nickname - guard dataCopy.count >= 50 else { return nil } - - var offset = 0 - - guard let originalMessageID = dataCopy.readUUID(at: &offset), - let ackID = dataCopy.readUUID(at: &offset) else { return nil } - - guard let recipientIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } - let recipientID = recipientIDData.hexEncodedString() - guard InputValidator.validatePeerID(recipientID) else { return nil } - - guard let hopCount = dataCopy.readUInt8(at: &offset), - InputValidator.validateHopCount(hopCount), - let timestamp = dataCopy.readDate(at: &offset), - InputValidator.validateTimestamp(timestamp), - let recipientNicknameRaw = dataCopy.readString(at: &offset), - let recipientNickname = InputValidator.validateNickname(recipientNicknameRaw) else { return nil } - - return DeliveryAck(originalMessageID: originalMessageID, - ackID: ackID, - recipientID: recipientID, - recipientNickname: recipientNickname, - timestamp: timestamp, - hopCount: hopCount) - } -} +// MARK: - Delivery Acknowledgments (removed) +// Legacy DeliveryAck structures are no longer used; delivery status flows via Noise payloads. // MARK: - Read Receipts @@ -456,460 +348,8 @@ struct ReadReceipt: Codable { } } -// MARK: - Handshake Requests -// Handshake request for pending messages -struct HandshakeRequest: Codable { - let requestID: String - let requesterID: String // Who needs the handshake - let requesterNickname: String // Nickname of requester - let targetID: String // Who should initiate handshake - let pendingMessageCount: UInt8 // Number of messages queued - let timestamp: Date - - init(requesterID: String, requesterNickname: String, targetID: String, pendingMessageCount: UInt8) { - self.requestID = UUID().uuidString - self.requesterID = requesterID - self.requesterNickname = requesterNickname - self.targetID = targetID - self.pendingMessageCount = pendingMessageCount - self.timestamp = Date() - } - - // For binary decoding - private init(requestID: String, requesterID: String, requesterNickname: String, targetID: String, pendingMessageCount: UInt8, timestamp: Date) { - self.requestID = requestID - self.requesterID = requesterID - self.requesterNickname = requesterNickname - self.targetID = targetID - self.pendingMessageCount = pendingMessageCount - self.timestamp = timestamp - } - - // MARK: - Binary Encoding - - func toBinaryData() -> Data { - var data = Data() - data.appendUUID(requestID) - - // RequesterID as 8-byte hex string - var requesterData = Data() - var tempID = requesterID - while tempID.count >= 2 && requesterData.count < 8 { - let hexByte = String(tempID.prefix(2)) - if let byte = UInt8(hexByte, radix: 16) { - requesterData.append(byte) - } - tempID = String(tempID.dropFirst(2)) - } - while requesterData.count < 8 { - requesterData.append(0) - } - data.append(requesterData) - - // TargetID as 8-byte hex string - var targetData = Data() - tempID = targetID - while tempID.count >= 2 && targetData.count < 8 { - let hexByte = String(tempID.prefix(2)) - if let byte = UInt8(hexByte, radix: 16) { - targetData.append(byte) - } - tempID = String(tempID.dropFirst(2)) - } - while targetData.count < 8 { - targetData.append(0) - } - data.append(targetData) - - data.appendUInt8(pendingMessageCount) - data.appendDate(timestamp) - data.appendString(requesterNickname) - return data - } - - static func fromBinaryData(_ data: Data) -> HandshakeRequest? { - // Create defensive copy - let dataCopy = Data(data) - - // Minimum size: UUID (16) + requesterID (8) + targetID (8) + count (1) + timestamp (8) + min nickname - guard dataCopy.count >= 42 else { return nil } - - var offset = 0 - - guard let requestID = dataCopy.readUUID(at: &offset) else { return nil } - - guard let requesterIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } - let requesterID = requesterIDData.hexEncodedString() - guard InputValidator.validatePeerID(requesterID) else { return nil } - - guard let targetIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } - let targetID = targetIDData.hexEncodedString() - guard InputValidator.validatePeerID(targetID) else { return nil } - - guard let pendingMessageCount = dataCopy.readUInt8(at: &offset), - let timestamp = dataCopy.readDate(at: &offset), - InputValidator.validateTimestamp(timestamp), - let requesterNicknameRaw = dataCopy.readString(at: &offset), - let requesterNickname = InputValidator.validateNickname(requesterNicknameRaw) else { return nil } - - return HandshakeRequest(requestID: requestID, - requesterID: requesterID, - requesterNickname: requesterNickname, - targetID: targetID, - pendingMessageCount: pendingMessageCount, - timestamp: timestamp) - } -} - -// MARK: - Protocol Acknowledgments - -// Protocol-level acknowledgment for reliable delivery -struct ProtocolAck: Codable { - let originalPacketID: String // ID of the packet being acknowledged - let ackID: String // Unique ID for this ACK - let senderID: String // Who sent the original packet - let receiverID: String // Who received and is acknowledging - let packetType: UInt8 // Type of packet being acknowledged - let timestamp: Date // When ACK was generated - let hopCount: UInt8 // Hops taken to reach receiver - - init(originalPacketID: String, senderID: String, receiverID: String, packetType: UInt8, hopCount: UInt8) { - self.originalPacketID = originalPacketID - self.ackID = UUID().uuidString - self.senderID = senderID - self.receiverID = receiverID - self.packetType = packetType - self.timestamp = Date() - self.hopCount = hopCount - } - - // Private init for binary decoding - private init(originalPacketID: String, ackID: String, senderID: String, receiverID: String, - packetType: UInt8, timestamp: Date, hopCount: UInt8) { - self.originalPacketID = originalPacketID - self.ackID = ackID - self.senderID = senderID - self.receiverID = receiverID - self.packetType = packetType - self.timestamp = timestamp - self.hopCount = hopCount - } - - func toBinaryData() -> Data { - var data = Data() - data.appendUUID(originalPacketID) - data.appendUUID(ackID) - - // Sender and receiver IDs as 8-byte hex strings - data.append(Data(hexString: senderID) ?? Data(repeating: 0, count: 8)) - data.append(Data(hexString: receiverID) ?? Data(repeating: 0, count: 8)) - - data.appendUInt8(packetType) - data.appendUInt8(hopCount) - data.appendDate(timestamp) - return data - } - - static func fromBinaryData(_ data: Data) -> ProtocolAck? { - let dataCopy = Data(data) - guard dataCopy.count >= 50 else { return nil } // 2 UUIDs + 2 IDs + type + hop + timestamp - - var offset = 0 - guard let originalPacketID = dataCopy.readUUID(at: &offset), - let ackID = dataCopy.readUUID(at: &offset), - let senderIDData = dataCopy.readFixedBytes(at: &offset, count: 8), - let receiverIDData = dataCopy.readFixedBytes(at: &offset, count: 8), - let packetType = dataCopy.readUInt8(at: &offset), - InputValidator.validateMessageType(packetType), - let hopCount = dataCopy.readUInt8(at: &offset), - InputValidator.validateHopCount(hopCount), - let timestamp = dataCopy.readDate(at: &offset), - InputValidator.validateTimestamp(timestamp) else { return nil } - - let senderID = senderIDData.hexEncodedString() - let receiverID = receiverIDData.hexEncodedString() - guard InputValidator.validatePeerID(senderID), - InputValidator.validatePeerID(receiverID) else { return nil } - - return ProtocolAck(originalPacketID: originalPacketID, - ackID: ackID, - senderID: senderID, - receiverID: receiverID, - packetType: packetType, - timestamp: timestamp, - hopCount: hopCount) - } -} - -// Protocol-level negative acknowledgment -struct ProtocolNack: Codable { - let originalPacketID: String // ID of the packet that failed - let nackID: String // Unique ID for this NACK - let senderID: String // Who sent the original packet - let receiverID: String // Who is reporting the failure - let packetType: UInt8 // Type of packet that failed - let timestamp: Date // When NACK was generated - let reason: String // Reason for failure - let errorCode: UInt8 // Numeric error code - - // Error codes - enum ErrorCode: UInt8 { - case unknown = 0 - case checksumFailed = 1 - case decryptionFailed = 2 - case malformedPacket = 3 - case unsupportedVersion = 4 - case resourceExhausted = 5 - case routingFailed = 6 - case sessionExpired = 7 - } - - init(originalPacketID: String, senderID: String, receiverID: String, - packetType: UInt8, reason: String, errorCode: ErrorCode = .unknown) { - self.originalPacketID = originalPacketID - self.nackID = UUID().uuidString - self.senderID = senderID - self.receiverID = receiverID - self.packetType = packetType - self.timestamp = Date() - self.reason = reason - self.errorCode = errorCode.rawValue - } - - // Private init for binary decoding - private init(originalPacketID: String, nackID: String, senderID: String, receiverID: String, - packetType: UInt8, timestamp: Date, reason: String, errorCode: UInt8) { - self.originalPacketID = originalPacketID - self.nackID = nackID - self.senderID = senderID - self.receiverID = receiverID - self.packetType = packetType - self.timestamp = timestamp - self.reason = reason - self.errorCode = errorCode - } - - func toBinaryData() -> Data { - var data = Data() - data.appendUUID(originalPacketID) - data.appendUUID(nackID) - - // Sender and receiver IDs as 8-byte hex strings - data.append(Data(hexString: senderID) ?? Data(repeating: 0, count: 8)) - data.append(Data(hexString: receiverID) ?? Data(repeating: 0, count: 8)) - - data.appendUInt8(packetType) - data.appendUInt8(errorCode) - data.appendDate(timestamp) - data.appendString(reason) - return data - } - - static func fromBinaryData(_ data: Data) -> ProtocolNack? { - let dataCopy = Data(data) - guard dataCopy.count >= 52 else { return nil } // Minimum size - - var offset = 0 - guard let originalPacketID = dataCopy.readUUID(at: &offset), - let nackID = dataCopy.readUUID(at: &offset), - let senderIDData = dataCopy.readFixedBytes(at: &offset, count: 8), - let receiverIDData = dataCopy.readFixedBytes(at: &offset, count: 8), - let packetType = dataCopy.readUInt8(at: &offset), - InputValidator.validateMessageType(packetType), - let errorCode = dataCopy.readUInt8(at: &offset), - let timestamp = dataCopy.readDate(at: &offset), - InputValidator.validateTimestamp(timestamp), - let reasonRaw = dataCopy.readString(at: &offset), - let reason = InputValidator.validateReasonString(reasonRaw) else { return nil } - - let senderID = senderIDData.hexEncodedString() - let receiverID = receiverIDData.hexEncodedString() - guard InputValidator.validatePeerID(senderID), - InputValidator.validatePeerID(receiverID) else { return nil } - - return ProtocolNack(originalPacketID: originalPacketID, - nackID: nackID, - senderID: senderID, - receiverID: receiverID, - packetType: packetType, - timestamp: timestamp, - reason: reason, - errorCode: errorCode) - } -} - -// MARK: - Peer Identity Rotation - -/// Announces a peer's cryptographic identity to enable secure communication. -/// Contains the peer's Noise static public key and supports identity rotation -/// by binding ephemeral peer IDs to stable cryptographic fingerprints. -/// - Note: Critical for establishing end-to-end encrypted channels -struct NoiseIdentityAnnouncement: Codable { - let peerID: String // Current ephemeral peer ID - let publicKey: Data // Noise static public key - let signingPublicKey: Data // Ed25519 signing public key - let nickname: String // Current nickname - let timestamp: Date // When this binding was created - let previousPeerID: String? // Previous peer ID (for smooth transition) - let signature: Data // Signature proving ownership - - init(peerID: String, publicKey: Data, signingPublicKey: Data, nickname: String, timestamp: Date, previousPeerID: String? = nil, signature: Data) { - self.peerID = peerID - self.publicKey = publicKey - self.signingPublicKey = signingPublicKey - // Trim whitespace from nickname - self.nickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) - self.timestamp = timestamp - self.previousPeerID = previousPeerID - self.signature = signature - } - - // Custom decoder to ensure nickname is trimmed - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.peerID = try container.decode(String.self, forKey: .peerID) - self.publicKey = try container.decode(Data.self, forKey: .publicKey) - self.signingPublicKey = try container.decode(Data.self, forKey: .signingPublicKey) - // Trim whitespace from decoded nickname - let rawNickname = try container.decode(String.self, forKey: .nickname) - self.nickname = rawNickname.trimmingCharacters(in: .whitespacesAndNewlines) - self.timestamp = try container.decode(Date.self, forKey: .timestamp) - self.previousPeerID = try container.decodeIfPresent(String.self, forKey: .previousPeerID) - self.signature = try container.decode(Data.self, forKey: .signature) - } - - func encode() -> Data? { - return try? JSONEncoder().encode(self) - } - - static func decode(from data: Data) -> NoiseIdentityAnnouncement? { - return try? JSONDecoder().decode(NoiseIdentityAnnouncement.self, from: data) - } - - // MARK: - Binary Encoding - - func toBinaryData() -> Data { - var data = Data() - - // Flags byte: bit 0 = hasPreviousPeerID - var flags: UInt8 = 0 - if previousPeerID != nil { flags |= 0x01 } - data.appendUInt8(flags) - - // PeerID as 8-byte hex string - var peerData = Data() - var tempID = peerID - while tempID.count >= 2 && peerData.count < 8 { - let hexByte = String(tempID.prefix(2)) - if let byte = UInt8(hexByte, radix: 16) { - peerData.append(byte) - } - tempID = String(tempID.dropFirst(2)) - } - while peerData.count < 8 { - peerData.append(0) - } - data.append(peerData) - - data.appendData(publicKey) - data.appendData(signingPublicKey) - data.appendString(nickname) - data.appendDate(timestamp) - - if let previousPeerID = previousPeerID { - // Previous PeerID as 8-byte hex string - var prevData = Data() - var tempPrevID = previousPeerID - while tempPrevID.count >= 2 && prevData.count < 8 { - let hexByte = String(tempPrevID.prefix(2)) - if let byte = UInt8(hexByte, radix: 16) { - prevData.append(byte) - } - tempPrevID = String(tempPrevID.dropFirst(2)) - } - while prevData.count < 8 { - prevData.append(0) - } - data.append(prevData) - } - - data.appendData(signature) - - return data - } - - static func fromBinaryData(_ data: Data) -> NoiseIdentityAnnouncement? { - // Create defensive copy - let dataCopy = Data(data) - - // Minimum size check: flags(1) + peerID(8) + min data lengths - guard dataCopy.count >= 20 else { return nil } - - var offset = 0 - - guard let flags = dataCopy.readUInt8(at: &offset) else { return nil } - let hasPreviousPeerID = (flags & 0x01) != 0 - - // Read peerID using safe method - guard let peerIDBytes = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } - let peerID = peerIDBytes.hexEncodedString() - guard InputValidator.validatePeerID(peerID) else { return nil } - - guard let publicKey = dataCopy.readData(at: &offset), - InputValidator.validatePublicKey(publicKey), - let signingPublicKey = dataCopy.readData(at: &offset), - InputValidator.validatePublicKey(signingPublicKey), - let rawNickname = dataCopy.readString(at: &offset), - let nickname = InputValidator.validateNickname(rawNickname), - let timestamp = dataCopy.readDate(at: &offset), - InputValidator.validateTimestamp(timestamp) else { return nil } - - var previousPeerID: String? = nil - if hasPreviousPeerID { - // Read previousPeerID using safe method - guard let prevIDBytes = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } - let prevID = prevIDBytes.hexEncodedString() - guard InputValidator.validatePeerID(prevID) else { return nil } - previousPeerID = prevID - } - - guard let signature = dataCopy.readData(at: &offset), - InputValidator.validateSignature(signature) else { return nil } - - return NoiseIdentityAnnouncement(peerID: peerID, - publicKey: publicKey, - signingPublicKey: signingPublicKey, - nickname: nickname, - timestamp: timestamp, - previousPeerID: previousPeerID, - signature: signature) - } -} - -// Binding between ephemeral peer ID and cryptographic identity -struct PeerIdentityBinding { - let currentPeerID: String // Current ephemeral ID - let fingerprint: String // Permanent cryptographic identity - let publicKey: Data // Noise static public key - let signingPublicKey: Data // Ed25519 signing public key - let nickname: String // Last known nickname - let bindingTimestamp: Date // When this binding was created - let signature: Data // Cryptographic proof of binding - - // Verify the binding signature - func verify() -> Bool { - let bindingData = currentPeerID.data(using: .utf8)! + publicKey + - String(Int64(bindingTimestamp.timeIntervalSince1970 * 1000)).data(using: .utf8)! - - do { - let signingKey = try Curve25519.Signing.PublicKey(rawRepresentation: signingPublicKey) - return signingKey.isValidSignature(signature, for: bindingData) - } catch { - return false - } - } -} +// PeerIdentityBinding removed (unused). // MARK: - Delivery Status @@ -1020,13 +460,7 @@ protocol BitchatDelegate: AnyObject { // Optional method to check if a fingerprint belongs to a favorite peer func isFavorite(fingerprint: String) -> Bool - // Delivery confirmation methods - func didReceiveDeliveryAck(_ ack: DeliveryAck) - func didReceiveReadReceipt(_ receipt: ReadReceipt) func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) - - // Peer availability tracking - func peerAvailabilityChanged(_ peerID: String, available: Bool) } // Provide default implementation to make it effectively optional @@ -1035,19 +469,41 @@ extension BitchatDelegate { return false } - func didReceiveDeliveryAck(_ ack: DeliveryAck) { - // Default empty implementation - } - - func didReceiveReadReceipt(_ receipt: ReadReceipt) { - // Default empty implementation - } - func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { // Default empty implementation } +} + +// MARK: - Noise Payload Helpers + +/// Helper to create typed Noise payloads +struct NoisePayload { + let type: NoisePayloadType + let data: Data - func peerAvailabilityChanged(_ peerID: String, available: Bool) { - // Default empty implementation + /// Encode payload with type prefix + func encode() -> Data { + var encoded = Data() + encoded.append(type.rawValue) + encoded.append(data) + return encoded + } + + /// Decode payload from data + static func decode(_ data: Data) -> NoisePayload? { + // Ensure we have at least 1 byte for the type + guard !data.isEmpty else { + return nil + } + + // Safely get the first byte + let firstByte = data[data.startIndex] + guard let type = NoisePayloadType(rawValue: firstByte) else { + return nil + } + + // Create a proper Data copy (not a subsequence) for thread safety + let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data() + return NoisePayload(type: type, data: payloadData) } } diff --git a/bitchat/Services/AutocompleteService.swift b/bitchat/Services/AutocompleteService.swift new file mode 100644 index 00000000..465567a5 --- /dev/null +++ b/bitchat/Services/AutocompleteService.swift @@ -0,0 +1,104 @@ +// +// AutocompleteService.swift +// bitchat +// +// Handles autocomplete suggestions for mentions and commands +// This is free and unencumbered software released into the public domain. +// + +import Foundation + +/// Manages autocomplete functionality for chat +class AutocompleteService { + private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: []) + private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: []) + + private let commands = [ + "/msg", "/who", "/clear", "/help", + "/hug", "/slap", "/fav", "/unfav", + "/block", "/unblock" + ] + + /// Get autocomplete suggestions for current text + func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) { + let textToPosition = String(text.prefix(cursorPosition)) + + // Check for mention autocomplete + if let (mentionSuggestions, mentionRange) = getMentionSuggestions(textToPosition, peers: peers) { + return (mentionSuggestions, mentionRange) + } + + // Don't handle command autocomplete here - ContentView handles it with better UI + // if let (commandSuggestions, commandRange) = getCommandSuggestions(textToPosition) { + // return (commandSuggestions, commandRange) + // } + + return ([], nil) + } + + /// Apply selected suggestion to text + func applySuggestion(_ suggestion: String, to text: String, range: NSRange) -> String { + guard let textRange = Range(range, in: text) else { return text } + + var replacement = suggestion + + // Add space after command if it takes arguments + if suggestion.hasPrefix("/") && needsArgument(command: suggestion) { + replacement += " " + } + + return text.replacingCharacters(in: textRange, with: replacement) + } + + // MARK: - Private Methods + + private func getMentionSuggestions(_ text: String, peers: [String]) -> ([String], NSRange)? { + guard let regex = mentionRegex else { return nil } + + let nsText = text as NSString + let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length)) + + guard let match = matches.last else { return nil } + + let fullRange = match.range(at: 0) + let captureRange = match.range(at: 1) + let prefix = nsText.substring(with: captureRange).lowercased() + + let suggestions = peers + .filter { $0.lowercased().hasPrefix(prefix) } + .sorted() + .prefix(5) + .map { "@\($0)" } + + return suggestions.isEmpty ? nil : (Array(suggestions), fullRange) + } + + private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? { + guard let regex = commandRegex else { return nil } + + let nsText = text as NSString + let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length)) + + guard let match = matches.last else { return nil } + + let fullRange = match.range(at: 0) + let captureRange = match.range(at: 1) + let prefix = nsText.substring(with: captureRange).lowercased() + + let suggestions = commands + .filter { $0.hasPrefix("/\(prefix)") } + .sorted() + .prefix(5) + + return suggestions.isEmpty ? nil : (Array(suggestions), fullRange) + } + + private func needsArgument(command: String) -> Bool { + switch command { + case "/who", "/clear", "/help": + return false + default: + return true + } + } +} \ No newline at end of file diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift deleted file mode 100644 index d4e9acd0..00000000 --- a/bitchat/Services/BluetoothMeshService.swift +++ /dev/null @@ -1,7499 +0,0 @@ -// -// BluetoothMeshService.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -/// -/// # BluetoothMeshService -/// -/// The core networking component that manages peer-to-peer Bluetooth LE connections -/// and implements the BitChat mesh networking protocol. -/// -/// ## Overview -/// This service is the heart of BitChat's decentralized architecture. It manages all -/// Bluetooth LE communications, enabling devices to form an ad-hoc mesh network without -/// any infrastructure. The service handles: -/// - Peer discovery and connection management -/// - Message routing and relay functionality -/// - Connection state tracking and recovery -/// - Integration with the Noise encryption layer -/// -/// ## Architecture -/// The service operates in a dual mode: -/// - **Central Mode**: Scans for and connects to other BitChat devices -/// - **Peripheral Mode**: Advertises its presence and accepts connections -/// -/// This dual-mode operation enables true peer-to-peer connectivity where any device -/// can initiate or accept connections, forming a resilient mesh topology. -/// -/// ## Mesh Networking -/// Messages are relayed through the mesh using a TTL (Time To Live) mechanism: -/// - Each message has a TTL that decrements at each hop -/// - Messages are cached to prevent loops (via Bloom filters) -/// - Store-and-forward ensures delivery to temporarily offline peers -/// -/// ## Connection Lifecycle -/// 1. **Discovery**: Devices scan and advertise simultaneously -/// 2. **Connection**: BLE connection established -/// 3. **Authentication**: Noise handshake for encrypted channels -/// 4. **Message Exchange**: Bidirectional communication -/// 5. **Disconnection**: Graceful cleanup and state preservation -/// -/// ## Security Integration -/// - Coordinates with NoiseEncryptionService for private messages -/// - Maintains peer identity mappings -/// - Handles lazy handshake initiation -/// - Ensures message authenticity -/// -/// ## Performance Optimizations -/// - Connection pooling and reuse -/// - Adaptive scanning based on battery level -/// - Message batching for efficiency -/// - Smart retry logic with exponential backoff -/// -/// ## Thread Safety -/// All public methods are thread-safe. The service uses: -/// - Serial queues for Core Bluetooth operations -/// - Thread-safe collections for peer management -/// - Atomic operations for state updates -/// -/// ## Error Handling -/// - Automatic reconnection for lost connections -/// - Graceful degradation when Bluetooth is unavailable -/// - Clear error reporting through BitchatDelegate -/// -/// ## Usage Example -/// ```swift -/// let meshService = BluetoothMeshService() -/// meshService.delegate = self -/// meshService.localUserID = "user123" -/// meshService.startMeshService() -/// ``` -/// - -import Foundation -import CoreBluetooth -import Combine -import CryptoKit -import os.log -#if os(macOS) -import AppKit -import IOKit.ps -#else -import UIKit -#endif - -// Hex encoding/decoding is now in BinaryEncodingUtils.swift - -// Extension for TimeInterval to Data conversion -extension TimeInterval { - var data: Data { - var value = self - return Data(bytes: &value, count: MemoryLayout.size) - } -} - - -// Peer connection state tracking -enum PeerConnectionState: CustomStringConvertible { - case disconnected - case connecting - case connected // BLE connected but not authenticated - case authenticating // Performing handshake - case authenticated // Handshake complete, ready for messages - - var isAvailable: Bool { - switch self { - case .authenticated: - return true - default: - return false - } - } - - var description: String { - switch self { - case .disconnected: return "disconnected" - case .connecting: return "connecting" - case .connected: return "connected" - case .authenticating: return "authenticating" - case .authenticated: return "authenticated" - } - } -} - -/// Manages all Bluetooth LE networking operations for the BitChat mesh network. -/// This class handles peer discovery, connection management, message routing, -/// and protocol negotiation. It acts as both a BLE central (scanner) and -/// peripheral (advertiser) simultaneously to enable true peer-to-peer connectivity. -class BluetoothMeshService: NSObject { - // MARK: - Constants - - static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") - static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") - - // MARK: - Core Bluetooth Properties - - private var centralManager: CBCentralManager? - private var peripheralManager: CBPeripheralManager? - private var discoveredPeripherals: [CBPeripheral] = [] - private var connectedPeripherals: [String: CBPeripheral] = [:] // Still needed for peripheral management - private var peripheralCharacteristics: [CBPeripheral: CBCharacteristic] = [:] - private var subscribedCharacteristics: Set = [] // Track peripheral+characteristic combos we've subscribed to - private var sentAnnounceToPeripheral: Set = [] // Track peripherals we've sent announce to - - // MARK: - Unified Peer Session Tracking - - /// Single source of truth for all peer data - private var peerSessions: [String: PeerSession] = [:] // peerID -> PeerSession - - // MARK: - Migration Helpers - - /// Update peripheral connection - safe to call from within collectionsQueue - private func updatePeripheralConnection(_ peerID: String, peripheral: CBPeripheral?, characteristic: CBCharacteristic? = nil) { - // Check if we're already on the collections queue - if DispatchQueue.getSpecific(key: collectionsQueueKey) != nil { - // Already on collections queue, update directly - if let session = peerSessions[peerID] { - session.updateBluetoothConnection(peripheral: peripheral, characteristic: characteristic) - } else if let peripheral = peripheral { - // Create session if needed - try to get a better nickname - let nickname = getBestAvailableNickname(for: peerID) - let session = PeerSession(peerID: peerID, nickname: nickname) - session.updateBluetoothConnection(peripheral: peripheral, characteristic: characteristic) - peerSessions[peerID] = session - } - - // Still need to update these legacy mappings for peripheral management - if let peripheral = peripheral { - connectedPeripherals[peerID] = peripheral - if let characteristic = characteristic { - peripheralCharacteristics[peripheral] = characteristic - } - } else { - connectedPeripherals.removeValue(forKey: peerID) - } - } else { - // Not on collections queue, dispatch sync - collectionsQueue.sync(flags: .barrier) { - if let session = peerSessions[peerID] { - session.updateBluetoothConnection(peripheral: peripheral, characteristic: characteristic) - } else if let peripheral = peripheral { - // Create session if needed - try to get a better nickname - let nickname = getBestAvailableNickname(for: peerID) - let session = PeerSession(peerID: peerID, nickname: nickname) - session.updateBluetoothConnection(peripheral: peripheral, characteristic: characteristic) - peerSessions[peerID] = session - } - - // Still need to update these legacy mappings for peripheral management - if let peripheral = peripheral { - connectedPeripherals[peerID] = peripheral - if let characteristic = characteristic { - peripheralCharacteristics[peripheral] = characteristic - } - } else { - connectedPeripherals.removeValue(forKey: peerID) - } - } - } - } - - /// Update last heard time for a peer - safe to call from within collectionsQueue - private func updateLastHeardFromPeer(_ peerID: String) { - let now = Date() - - // Check if we're already on the collections queue - if DispatchQueue.getSpecific(key: collectionsQueueKey) != nil { - // Already on collections queue, update directly - if let session = peerSessions[peerID] { - session.lastHeardFromPeer = now - } - } else { - // Not on collections queue, dispatch sync - collectionsQueue.sync(flags: .barrier) { - if let session = peerSessions[peerID] { - session.lastHeardFromPeer = now - } - } - } - } - - /// Update last successful message time for a peer - safe to call from within collectionsQueue - private func updateLastSuccessfulMessageTime(_ peerID: String) { - let now = Date() - - // Check if we're already on the collections queue - if DispatchQueue.getSpecific(key: collectionsQueueKey) != nil { - // Already on collections queue, update directly - if let session = peerSessions[peerID] { - session.lastSuccessfulMessageTime = now - } - } else { - // Not on collections queue, dispatch sync - collectionsQueue.sync(flags: .barrier) { - if let session = peerSessions[peerID] { - session.lastSuccessfulMessageTime = now - } - } - } - } - - /// Update last connection time for a peer - safe to call from within collectionsQueue - private func updateLastConnectionTime(_ peerID: String) { - let now = Date() - - // Check if we're already on the collections queue - if DispatchQueue.getSpecific(key: collectionsQueueKey) != nil { - // Already on collections queue, update directly - if let session = peerSessions[peerID] { - session.lastConnectionTime = now - } - } else { - // Not on collections queue, dispatch sync - collectionsQueue.sync(flags: .barrier) { - if let session = peerSessions[peerID] { - session.lastConnectionTime = now - } - } - } - } - - // MARK: - Connection Tracking - - // MARK: - Peer Availability - - // Peer availability tracking - private let peerAvailabilityTimeout: TimeInterval = 60.0 // Mark unavailable after 60s of no response - increased for stability - private var availabilityCheckTimer: Timer? - - // MARK: - Peripheral Management - - private var characteristic: CBMutableCharacteristic? - private var subscribedCentrals: [CBCentral] = [] - private var centralToPeerID: [UUID: String] = [:] // Maps central UUID to peer ID - private var sentAnnounceBackToCentral: Set = [] // Track which centrals we've sent announce back to - private var pendingDataToSend: [(Data, CBMutableCharacteristic, [CBCentral]?)] = [] // Queue for when transmit buffer is full - - // MARK: - Thread-Safe Collections - - private let collectionsQueue = DispatchQueue(label: "bitchat.collections", attributes: .concurrent) - private let collectionsQueueKey = DispatchSpecificKey() - private var lastLoggedConnectionLimit: Int = 0 - - // MARK: - Encryption Queues - - // Per-peer encryption queues to prevent nonce desynchronization - private var peerEncryptionQueues: [String: DispatchQueue] = [:] - private let encryptionQueuesLock = NSLock() - - // MARK: - Peer Identity Rotation - // Mappings between ephemeral peer IDs and permanent fingerprints - private var peerIDToFingerprint: [String: String] = [:] // PeerID -> Fingerprint - private var fingerprintToPeerID: [String: String] = [:] // Fingerprint -> Current PeerID - private var peerIdentityBindings: [String: PeerIdentityBinding] = [:] // Fingerprint -> Full binding - private var rotationTimestamp: Date? // When we last rotated - private var rotationLocked = false // Prevent rotation during critical operations - private var rotationTimer: Timer? // Timer for scheduled rotations - - // MARK: - Identity Cache - // In-memory cache for peer public keys to avoid keychain lookups - private var peerPublicKeyCache: [String: Data] = [:] // PeerID -> Public Key Data - private var peerSigningKeyCache: [String: Data] = [:] // PeerID -> Signing Key Data - private let identityCacheTTL: TimeInterval = 3600.0 // 1 hour TTL - private var identityCacheTimestamps: [String: Date] = [:] // Track when entries were cached - - // MARK: - Delegates and Services - - weak var delegate: BitchatDelegate? - private let noiseService = NoiseEncryptionService() - private let handshakeCoordinator = NoiseHandshakeCoordinator() - - // MARK: - Protocol Message Deduplication - - private struct DedupKey: Hashable { - let peerID: String // Use "broadcast" for broadcast messages - let messageType: MessageType - let contentHash: Int? // Optional hash of content for messages that vary - } - - private struct DedupEntry { - let sentAt: Date - let expiresAt: Date - } - - private var protocolMessageDedup: [DedupKey: DedupEntry] = [:] - private let dedupDurations: [MessageType: TimeInterval] = [ - .announce: 5.0, // Suppress duplicate announces for 5 seconds - .noiseIdentityAnnounce: 5.0, // Suppress noise identity announcements for 5 seconds - .leave: 1.0 // Suppress leave messages for 1 second - ] - - // MARK: - Write Queue for Disconnected Peripherals - private struct QueuedWrite { - let data: Data - let peripheralID: String - let peerID: String? - let timestamp: Date - let retryCount: Int - } - - private var writeQueue: [String: [QueuedWrite]] = [:] // PeripheralID -> Queue of writes - private let writeQueueLock = NSLock() - private let maxWriteQueueSize = 50 // Max queued writes per peripheral - private let maxWriteRetries = 3 - private let writeQueueTTL: TimeInterval = 60.0 // Expire queued writes after 1 minute - private var writeQueueTimer: Timer? // Timer for processing expired writes - - // MARK: - Connection Pooling - private var maxConnectedPeripherals: Int { - calculateDynamicConnectionLimit() - } - private let maxScanningDuration: TimeInterval = 5.0 // Stop scanning after 5 seconds to save battery - - func getNoiseService() -> NoiseEncryptionService { - return noiseService - } - - - private func cleanExpiredIdentityCache() { - collectionsQueue.async(flags: .barrier) { [weak self] in - guard let self = self else { return } - - let now = Date() - var expiredPeerIDs: [String] = [] - - for (peerID, timestamp) in self.identityCacheTimestamps { - if now.timeIntervalSince(timestamp) >= self.identityCacheTTL { - expiredPeerIDs.append(peerID) - } - } - - for peerID in expiredPeerIDs { - self.peerPublicKeyCache.removeValue(forKey: peerID) - self.peerSigningKeyCache.removeValue(forKey: peerID) - self.identityCacheTimestamps.removeValue(forKey: peerID) - } - - } - } - - // MARK: - Write Queue Management - - private func writeToPeripheral(_ data: Data, peripheral: CBPeripheral, characteristic: CBCharacteristic, peerID: String? = nil) { - let peripheralID = peripheral.identifier.uuidString - - // Double check the peripheral state to avoid API misuse - guard peripheral.state == .connected else { - // Queue write for disconnected peripheral - queueWrite(data: data, peripheralID: peripheralID, peerID: peerID) - return - } - - // Verify characteristic is valid and writable - guard characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) else { - SecureLogger.log("Characteristic does not support writing for peripheral \(peripheralID)", - category: SecureLogger.session, level: .warning) - return - } - - // Direct write if connected - let writeType: CBCharacteristicWriteType = data.count > 512 ? .withResponse : .withoutResponse - peripheral.writeValue(data, for: characteristic, type: writeType) - - // Update activity tracking - updatePeripheralActivity(peripheralID) - } - - private func queueWrite(data: Data, peripheralID: String, peerID: String?) { - writeQueueLock.lock() - defer { writeQueueLock.unlock() } - - // Check backpressure - drop oldest if queue is full - var queue = writeQueue[peripheralID] ?? [] - - if queue.count >= maxWriteQueueSize { - // Remove oldest entries - let removeCount = queue.count - maxWriteQueueSize + 1 - queue.removeFirst(removeCount) - SecureLogger.log("Write queue full for \(peripheralID), dropped \(removeCount) oldest writes", - category: SecureLogger.session, level: .warning) - } - - let queuedWrite = QueuedWrite( - data: data, - peripheralID: peripheralID, - peerID: peerID, - timestamp: Date(), - retryCount: 0 - ) - - queue.append(queuedWrite) - writeQueue[peripheralID] = queue - - } - - private func processWriteQueue(for peripheral: CBPeripheral) { - guard peripheral.state == .connected, - let characteristic = peripheralCharacteristics[peripheral] else { return } - - let peripheralID = peripheral.identifier.uuidString - - writeQueueLock.lock() - let queue = writeQueue[peripheralID] ?? [] - writeQueue[peripheralID] = [] - writeQueueLock.unlock() - - - // Process queued writes with small delay between them - for (index, queuedWrite) in queue.enumerated() { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.01) { [weak self] in - guard let self = self, - peripheral.state == .connected, - characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) else { - return - } - - let writeType: CBCharacteristicWriteType = queuedWrite.data.count > 512 ? .withResponse : .withoutResponse - peripheral.writeValue(queuedWrite.data, for: characteristic, type: writeType) - - // Update activity tracking - self.updatePeripheralActivity(peripheralID) - } - } - } - - private func cleanExpiredWriteQueues() { - writeQueueLock.lock() - defer { writeQueueLock.unlock() } - - let now = Date() - var expiredWrites = 0 - - for (peripheralID, queue) in writeQueue { - let filteredQueue = queue.filter { write in - now.timeIntervalSince(write.timestamp) < writeQueueTTL - } - - expiredWrites += queue.count - filteredQueue.count - - if filteredQueue.isEmpty { - writeQueue.removeValue(forKey: peripheralID) - } else { - writeQueue[peripheralID] = filteredQueue - } - } - - } - // MARK: - Message Processing - - private let messageQueue = DispatchQueue(label: "bitchat.messageQueue", attributes: .concurrent) // Concurrent queue with barriers - - // Message state tracking for better duplicate detection - private struct MessageState { - let firstSeen: Date - var lastSeen: Date - var seenCount: Int - var relayed: Bool - var acknowledged: Bool - - mutating func updateSeen() { - lastSeen = Date() - seenCount += 1 - } - } - - private var processedMessages = [String: MessageState]() // Track full message state - private let processedMessagesLock = NSLock() - private let maxProcessedMessages = 10000 - - // Special handling for identity announces to prevent duplicates - private var recentIdentityAnnounces = [String: Date]() // peerID -> last seen time - private let identityAnnounceDuplicateWindow: TimeInterval = 30.0 // 30 second window - - // MARK: - Network State Management - - private let maxTTL: UInt8 = 7 // Maximum hops for long-distance delivery - private var hasNotifiedNetworkAvailable = false // Track if we've notified about network availability - private var lastNetworkNotificationTime: Date? // Track when we last sent a network notification - private var networkBecameEmptyTime: Date? // Track when the network became empty - private let networkNotificationCooldown: TimeInterval = 300 // 5 minutes between notifications - private let networkEmptyResetDelay: TimeInterval = 60 // 1 minute before resetting notification flag - private var intentionalDisconnects = Set() // Track peripherals we're disconnecting intentionally - private var gracefullyLeftPeers = Set() // Track peers that sent leave messages - private var gracefulLeaveTimestamps: [String: Date] = [:] // Track when peers left - private let gracefulLeaveExpirationTime: TimeInterval = 300.0 // 5 minutes - private var recentDisconnectNotifications: [String: Date] = [:] // Track recent disconnect notifications to prevent duplicates - private let disconnectNotificationDedupeWindow: TimeInterval = 2.0 // 2 seconds window for deduplication - private var peerLastSeenTimestamps = LRUCache(maxSize: 100) // Bounded cache for peer timestamps - private var cleanupTimer: Timer? // Timer to clean up stale peers - - // MARK: - Message Tracking - - private let deliveredMessages = BoundedSet(maxSize: 5000) // Bounded to prevent memory growth - private let receivedMessageTimestamps = LRUCache(maxSize: 1000) // Bounded cache - private let recentlySentMessages = BoundedSet(maxSize: 500) // Short-term bounded cache - private let lastMessageFromPeer = LRUCache(maxSize: 100) // Bounded cache - private let processedNoiseMessages = BoundedSet(maxSize: 1000) // Bounded cache - - // MARK: - Battery and Performance Optimization - - // Battery and range optimizations - private var scanDutyCycleTimer: Timer? - private var isActivelyScanning = true - private var activeScanDuration: TimeInterval = 5.0 // will be adjusted based on battery - private var scanPauseDuration: TimeInterval = 10.0 // will be adjusted based on battery - private var batteryMonitorTimer: Timer? - private var currentBatteryLevel: Float = 1.0 // Default to full battery - - // App state tracking - private var isAppInForeground = true - - // Background task management - #if os(iOS) - private var backgroundTask: UIBackgroundTaskIdentifier = .invalid - private var scanBackgroundTask: UIBackgroundTaskIdentifier = .invalid - private var advertiseBackgroundTask: UIBackgroundTaskIdentifier = .invalid - #endif - - // Core Bluetooth state restoration identifiers - private static let centralManagerRestorationID = "com.bitchat.central" - private static let peripheralManagerRestorationID = "com.bitchat.peripheral" - - // Battery optimizer integration - private let batteryOptimizer = BatteryOptimizer.shared - private var batteryOptimizerCancellables = Set() - - // Rescan rate limiting - private var lastRescanTime: Date = Date.distantPast - private let minRescanInterval: TimeInterval = 2.0 - - // Peer list update debouncing - private var peerListUpdateTimer: Timer? - private let peerListUpdateDebounceInterval: TimeInterval = 0.1 // 100ms debounce for more responsive updates - - // Track when we last sent identity announcements to prevent flooding - private var lastIdentityAnnounceTimes: [String: Date] = [:] - private let identityAnnounceMinInterval: TimeInterval = 10.0 // Minimum 10 seconds between announcements per peer - - // Track handshake attempts to handle timeouts - private var handshakeAttemptTimes: [String: Date] = [:] - private let handshakeTimeout: TimeInterval = 5.0 // 5 seconds before retrying - - // Pending private messages waiting for handshake - private var pendingPrivateMessages: [String: [(content: String, recipientNickname: String, messageID: String)]] = [:] - - // MARK: - Noise Protocol State - - // Noise session state tracking for lazy handshakes - private var noiseSessionStates: [String: LazyHandshakeState] = [:] - - // MARK: - Connection State - - // Connection state tracking - private var peerConnectionStates: [String: PeerConnectionState] = [:] - private let connectionStateQueue = DispatchQueue(label: "chat.bitchat.connectionState", attributes: .concurrent) - - // MARK: - Protocol ACK Tracking - - // Protocol-level ACK tracking - private var pendingAcks: [String: (packet: BitchatPacket, timestamp: Date, retries: Int)] = [:] - private let ackTimeout: TimeInterval = 5.0 // 5 seconds to receive ACK - private let maxAckRetries = 3 - private var ackTimer: Timer? - private var advertisingTimer: Timer? // Timer for interval-based advertising - private var connectionKeepAliveTimer: Timer? // Timer to send keepalive pings - private let keepAliveInterval: TimeInterval = 20.0 // Send keepalive every 20 seconds - - // MARK: - Timing and Delays - - // Timing randomization for privacy (now with exponential distribution) - private let minMessageDelay: TimeInterval = 0.01 // 10ms minimum for faster sync - private let maxMessageDelay: TimeInterval = 0.1 // 100ms maximum for faster sync - - // MARK: - Fragment Handling - - // Fragment handling with security limits - private var incomingFragments: [String: [Int: Data]] = [:] // fragmentID -> [index: data] - private var fragmentMetadata: [String: (originalType: UInt8, totalFragments: Int, timestamp: Date)] = [:] - private let maxFragmentSize = 469 // 512 bytes max MTU - 43 bytes for headers and metadata - private let maxConcurrentFragmentSessions = 20 // Limit concurrent fragment sessions to prevent DoS - private let fragmentTimeout: TimeInterval = 30 // 30 seconds timeout for incomplete fragments - - // MARK: - Peer Identity - - var myPeerID: String { - didSet { - if oldValue != "" && oldValue != myPeerID { - SecureLogger.log("📍 MY PEER ID CHANGED: \(oldValue) -> \(myPeerID)", - category: SecureLogger.security, level: .warning) - } - } - } - - // MARK: - Scaling Optimizations - - // Connection pooling - private var connectionPool: [String: CBPeripheral] = [:] - private var connectionAttempts: [String: Int] = [:] - private var connectionBackoff: [String: TimeInterval] = [:] - private var lastActivityByPeripheralID: [String: Date] = [:] // Track last activity for LRU - private var peerIDByPeripheralID: [String: String] = [:] // Map peripheral ID to peer ID - private let maxConnectionAttempts = 3 - private let baseBackoffInterval: TimeInterval = 1.0 - - // MARK: - Peripheral Mapping - - // Simplified peripheral mapping system - private struct PeripheralMapping { - let peripheral: CBPeripheral - var peerID: String? // nil until we receive announce - var lastActivity: Date - - var isIdentified: Bool { peerID != nil } - } - private var peripheralMappings: [String: PeripheralMapping] = [:] // peripheralID -> mapping - - // Helper methods for peripheral mapping - private func registerPeripheral(_ peripheral: CBPeripheral) { - let peripheralID = peripheral.identifier.uuidString - peripheralMappings[peripheralID] = PeripheralMapping( - peripheral: peripheral, - peerID: nil, - lastActivity: Date() - ) - } - - private func updatePeripheralMapping(peripheralID: String, peerID: String) { - - guard var mapping = peripheralMappings[peripheralID] else { - SecureLogger.log("[WARNING] No peripheral mapping found for \(peripheralID.prefix(8))", - category: SecureLogger.session, level: .warning) - return - } - - // Remove old temp mapping if it exists - let tempID = peripheralID - if connectedPeripherals[tempID] != nil { - connectedPeripherals.removeValue(forKey: tempID) - } - - mapping.peerID = peerID - mapping.lastActivity = Date() - peripheralMappings[peripheralID] = mapping - - // Update legacy mappings - peerIDByPeripheralID[peripheralID] = peerID - updatePeripheralConnection(peerID, peripheral: mapping.peripheral) - - - // Update PeerSession - if let session = peerSessions[peerID] { - session.updateBluetoothConnection(peripheral: mapping.peripheral, characteristic: nil) - } else { - // This is a truly new peer session - SecureLogger.log("New peer connected: \(peerID)", - category: SecureLogger.session, level: .info) - let nickname = getBestAvailableNickname(for: peerID) - let session = PeerSession(peerID: peerID, nickname: nickname) - session.updateBluetoothConnection(peripheral: mapping.peripheral, characteristic: nil) - peerSessions[peerID] = session - } - } - - private func findPeripheralForPeerID(_ peerID: String) -> CBPeripheral? { - for (_, mapping) in peripheralMappings { - if mapping.peerID == peerID { - return mapping.peripheral - } - } - return nil - } - - // MARK: - Probabilistic Flooding - - // Probabilistic flooding - private var relayProbability: Double = 1.0 // Start at 100%, decrease with peer count - private let minRelayProbability: Double = 0.4 // Minimum 40% relay chance - ensures coverage - - - // MARK: - Bloom Filter - - // Optimized Bloom filter for efficient duplicate detection - private var messageBloomFilter = OptimizedBloomFilter(expectedItems: 2000, falsePositiveRate: 0.01) - private var bloomFilterResetTimer: Timer? - - // MARK: - Consolidated Timers - - // Consolidated timers for better performance - private var highFrequencyTimer: Timer? // 2s - critical real-time tasks - private var mediumFrequencyTimer: Timer? // 20s - regular maintenance - private var lowFrequencyTimer: Timer? // 5min - cleanup and optimization - - // Track execution counters for tasks that don't run every timer tick - private var mediumTimerTickCount = 0 - private var lowTimerTickCount = 0 - - // MARK: - Network Size Estimation - - // Network size estimation - private var estimatedNetworkSize: Int { - return collectionsQueue.sync { - let activePeerCount = peerSessions.values.filter { $0.isActivePeer }.count - let peripheralCount = connectedPeripherals.count - let result = max(activePeerCount, peripheralCount) - return result - } - } - - // Dynamic connection limit calculation based on network size and battery mode - private func calculateDynamicConnectionLimit() -> Int { - let powerMode = batteryOptimizer.currentPowerMode - let baseLimit = powerMode.maxConnections - let nearbyPeers = estimatedNetworkSize - - // For small groups, try to maintain full mesh connectivity - if nearbyPeers > 0 && nearbyPeers < 10 { - // Override battery limits for small groups to prevent thrashing - let dynamicLimit = max(baseLimit, nearbyPeers + 1) - if dynamicLimit != baseLimit { - if abs(dynamicLimit - lastLoggedConnectionLimit) > 5 { - SecureLogger.log("Connection limit changed: \(dynamicLimit)", - category: SecureLogger.session, level: .info) - lastLoggedConnectionLimit = dynamicLimit - } - } - return dynamicLimit - } - - // For larger groups, scale up the limit but cap it reasonably - let groupMultiplier = min(2.0, 1.0 + (Double(nearbyPeers) / 10.0)) - let scaledLimit = Int(Double(baseLimit) * groupMultiplier) - - // Cap at a reasonable maximum to prevent resource exhaustion - let finalLimit = min(scaledLimit, 30) - - if finalLimit != baseLimit { - if abs(finalLimit - lastLoggedConnectionLimit) > 5 { - SecureLogger.log("Connection limit changed: \(finalLimit)", - category: SecureLogger.session, level: .info) - lastLoggedConnectionLimit = finalLimit - } - } - - return finalLimit - } - - // Adaptive parameters based on network size - private var adaptiveTTL: UInt8 { - // Keep TTL high enough for messages to travel far - let networkSize = estimatedNetworkSize - if networkSize <= 20 { - return 6 // Small networks: max distance - } else if networkSize <= 50 { - return 5 // Medium networks: still good reach - } else if networkSize <= 100 { - return 4 // Large networks: reasonable reach - } else { - return 3 // Very large networks: minimum viable - } - } - - // MARK: - Relay Cancellation - - // Relay cancellation mechanism to prevent duplicate relays - private var pendingRelays: [String: DispatchWorkItem] = [:] // messageID -> relay task - private let pendingRelaysLock = NSLock() - private let relayCancellationWindow: TimeInterval = 0.05 // 50ms window to detect other relays - - // MARK: - Memory Management - - // Global memory limits to prevent unbounded growth - private let maxPendingPrivateMessages = 100 - private let maxMemoryUsageBytes = 50 * 1024 * 1024 // 50MB limit - private var memoryCleanupTimer: Timer? - - // Track acknowledged packets to prevent unnecessary retries - private var acknowledgedPackets = Set() - private let acknowledgedPacketsLock = NSLock() - - // MARK: - Rate Limiting - - // Rate limiting for flood control with separate limits for different message types - private var messageRateLimiter: [String: [Date]] = [:] // peerID -> recent message timestamps - private var protocolMessageRateLimiter: [String: [Date]] = [:] // peerID -> protocol msg timestamps - private let rateLimiterLock = NSLock() - private let rateLimitWindow: TimeInterval = 60.0 // 1 minute window - private let maxChatMessagesPerPeerPerMinute = 300 // Allow fast typing (5 msgs/sec) - private let maxProtocolMessagesPerPeerPerMinute = 100 // Protocol messages - private let maxTotalMessagesPerMinute = 2000 // Increased for legitimate use - private var totalMessageTimestamps: [Date] = [] - - // MARK: - BLE Advertisement - - // BLE advertisement for lightweight presence - private var advertisementData: [String: Any] = [:] - private var isAdvertising = false - - - // Removed getPublicKeyFingerprint - no longer needed with Noise - - // MARK: - Peer Identity Mapping - - /// Retrieves the cryptographic fingerprint for a given peer. - /// - Parameter peerID: The ephemeral peer ID - /// - Returns: The peer's Noise static key fingerprint if known, nil otherwise - /// - Note: This fingerprint remains stable across peer ID rotations - func getPeerFingerprint(_ peerID: String) -> String? { - // Check PeerSession first for O(1) lookup - if let fingerprint = collectionsQueue.sync(execute: { - peerSessions[peerID]?.fingerprint - }) { - return fingerprint - } - - // Fallback to noise service - return noiseService.getPeerFingerprint(peerID) - } - - // Get fingerprint for a peer ID - func getFingerprint(for peerID: String) -> String? { - return collectionsQueue.sync { - // Check PeerSession first for O(1) lookup - if let fingerprint = peerSessions[peerID]?.fingerprint { - return fingerprint - } - - // Fallback to peerIDToFingerprint map - return peerIDToFingerprint[peerID] - } - } - - /// Checks if a given peer ID belongs to the local device. - /// - Parameter peerID: The peer ID to check - /// - Returns: true if this is our current or recent peer ID, false otherwise - /// - Note: Accounts for grace period during peer ID rotation - func isPeerIDOurs(_ peerID: String) -> Bool { - if peerID == myPeerID { - return true - } - - return false - } - - // MARK: - Peer Session Management - - /// Get or create a peer session - private func getPeerSession(_ peerID: String) -> PeerSession { - return collectionsQueue.sync(flags: .barrier) { - if let session = peerSessions[peerID] { - return session - } - // Try to get a better nickname from various sources before defaulting to "Unknown" - let nickname = getBestAvailableNickname(for: peerID) - let newSession = PeerSession(peerID: peerID, nickname: nickname) - peerSessions[peerID] = newSession - return newSession - } - } - - /// Get peer session if it exists - private func getExistingPeerSession(_ peerID: String) -> PeerSession? { - return collectionsQueue.sync { - return peerSessions[peerID] - } - } - - /// Update peer session with nickname - private func updatePeerSessionNickname(_ peerID: String, nickname: String) { - collectionsQueue.async(flags: .barrier) { - if let session = self.peerSessions[peerID] { - session.nickname = nickname - } else { - let session = PeerSession(peerID: peerID, nickname: nickname) - self.peerSessions[peerID] = session - } - } - } - - /// Get the best available nickname for a peer ID from various sources - /// This method should be called from within collectionsQueue context - private func getBestAvailableNickname(for peerID: String) -> String { - // Check if ChatViewModel has peer information available - if let chatViewModel = delegate as? ChatViewModel { - let peers = chatViewModel.allPeers - if let peer = peers.first(where: { $0.id == peerID }), !peer.displayName.isEmpty && peer.displayName != "Unknown" { - return peer.displayName - } - } - - // Generate a more user-friendly temporary nickname based on peer ID - // This is better than "Unknown" and helps users distinguish between different peers - return "anon\(peerID.prefix(4))" - } - - /// Create a peer session only if we have a valid connection - /// This prevents ghost sessions from being created - private func createPeerSessionIfValid(_ peerID: String, peripheral: CBPeripheral? = nil, nickname: String = "Unknown") -> PeerSession? { - return collectionsQueue.sync(flags: .barrier) { - // Check if we already have a session - if let existingSession = self.peerSessions[peerID] { - return existingSession - } - - // Only create new sessions if we have a peripheral connection or it's for authenticated purposes - guard peripheral != nil && peripheral?.state == .connected else { - SecureLogger.log("Refusing to create session for \(peerID) without peripheral connection", - category: SecureLogger.session, level: .debug) - return nil - } - - let session = PeerSession(peerID: peerID, nickname: nickname) - if let peripheral = peripheral { - session.updateBluetoothConnection(peripheral: peripheral, characteristic: nil) - } - self.peerSessions[peerID] = session - return session - } - } - - /// Clean up Unknown peers that haven't announced themselves and old disconnected sessions - private func cleanupUnknownPeers() { - collectionsQueue.async(flags: .barrier) { - let sessionsToRemove = self.peerSessions.filter { (peerID, session) in - // Remove if it's an Unknown peer without a proper announcement - if session.nickname == "Unknown" && - !session.hasReceivedAnnounce && - session.peripheral == nil && - Date().timeIntervalSince(session.lastSeen) > 3.0 { - return true - } - - // Remove disconnected non-favorite sessions after 5 minutes - if !session.isConnected && !session.isActivePeer && - Date().timeIntervalSince(session.lastSeen) > 300.0 { - // Non-favorites will be cleaned up - // Favorites are kept indefinitely - return true - } - - return false - } - - for (peerID, session) in sessionsToRemove { - if session.nickname == "Unknown" { - SecureLogger.log("Removing Unknown peer \(peerID) - no announce received", - category: SecureLogger.session, level: .debug) - } else { - SecureLogger.log("Removing disconnected non-favorite peer \(peerID) (\(session.nickname)) - disconnected too long", - category: SecureLogger.session, level: .debug) - } - self.peerSessions.removeValue(forKey: peerID) - } - - if !sessionsToRemove.isEmpty { - DispatchQueue.main.async { - self.notifyPeerListUpdate(immediate: true) - } - } - } - } - - /// Clean up stale peer sessions - private func cleanupStaleSessions() { - collectionsQueue.async(flags: .barrier) { - // Remove stale sessions and ghost sessions without proper connections - let sessionsToRemove = self.peerSessions.filter { (peerID, session) in - // Remove if stale - if session.isStale { - return true - } - // Remove if it's a ghost session (no peripheral, not authenticated, and has default nickname) - if session.peripheral == nil && - !session.isAuthenticated && - !session.hasEstablishedNoiseSession && - !session.hasReceivedAnnounce && - (session.nickname == "Unknown" || session.nickname.isEmpty) { - return true - } - return false - } - - for (peerID, session) in sessionsToRemove { - SecureLogger.log("Removing stale/ghost session for \(peerID) (nickname: \(session.nickname))", - category: SecureLogger.session, level: .debug) - self.peerSessions.removeValue(forKey: peerID) - } - } - } - - /// Updates the identity binding for a peer when they rotate their ephemeral ID. - /// - Parameters: - /// - newPeerID: The peer's new ephemeral ID - /// - fingerprint: The peer's stable cryptographic fingerprint - /// - binding: The complete identity binding information - /// - Note: This maintains continuity of identity across peer ID rotations - func updatePeerBinding(_ newPeerID: String, fingerprint: String, binding: PeerIdentityBinding) { - // Use async to ensure we're not blocking during view updates - collectionsQueue.async(flags: .barrier) { [weak self] in - guard let self = self else { return } - - var oldPeerID: String? = nil - - // Remove old peer ID mapping if exists - if let existingPeerID = self.fingerprintToPeerID[fingerprint], existingPeerID != newPeerID { - oldPeerID = existingPeerID - SecureLogger.log("Peer ID rotation detected: \(existingPeerID) -> \(newPeerID) for fingerprint \(fingerprint)", category: SecureLogger.security, level: .info) - - // Transfer gracefullyLeftPeers state from old to new peer ID - if self.gracefullyLeftPeers.contains(existingPeerID) { - self.gracefullyLeftPeers.remove(existingPeerID) - self.gracefullyLeftPeers.insert(newPeerID) - SecureLogger.log("Transferred gracefullyLeft state from \(existingPeerID) to \(newPeerID)", - category: SecureLogger.session, level: .debug) - } - - self.peerIDToFingerprint.removeValue(forKey: existingPeerID) - - // Transfer session data from old to new peer ID - if let oldSession = self.peerSessions[existingPeerID] { - // Extract all data from old session - let nickname = oldSession.nickname - let lastHeard = oldSession.lastHeardFromPeer - let peripheral = oldSession.peripheral - - // Create or update session for new peer ID - if let newSession = self.peerSessions[newPeerID] { - newSession.nickname = nickname - newSession.lastHeardFromPeer = lastHeard - if let peripheral = peripheral { - newSession.updateBluetoothConnection(peripheral: peripheral, characteristic: nil) - } - } else { - let newSession = PeerSession(peerID: newPeerID, nickname: nickname) - newSession.lastHeardFromPeer = lastHeard - if let peripheral = peripheral { - newSession.updateBluetoothConnection(peripheral: peripheral, characteristic: nil) - } - self.peerSessions[newPeerID] = newSession - } - - // Mark old session as inactive - oldSession.isActivePeer = false - oldSession.isConnected = false - } - - // Transfer any connected peripherals in the legacy mapping - if let peripheral = self.connectedPeripherals[existingPeerID] { - self.connectedPeripherals.removeValue(forKey: existingPeerID) - self.connectedPeripherals[newPeerID] = peripheral - } - - // Clean up connection state for old peer ID - self.peerConnectionStates.removeValue(forKey: existingPeerID) - // Don't transfer connection state - let it be re-established naturally - - // Clean up any pending messages for old peer ID - self.pendingPrivateMessages.removeValue(forKey: existingPeerID) - // Don't transfer pending messages - they would be stale anyway - } - - // Add new mapping - self.peerIDToFingerprint[newPeerID] = fingerprint - self.fingerprintToPeerID[fingerprint] = newPeerID - self.peerIdentityBindings[fingerprint] = binding - - // Cache public keys to avoid keychain lookups - self.peerPublicKeyCache[newPeerID] = binding.publicKey - self.peerSigningKeyCache[newPeerID] = binding.signingPublicKey - self.identityCacheTimestamps[newPeerID] = Date() - - // Also update nickname from binding - if let session = self.peerSessions[newPeerID] { - session.nickname = binding.nickname - } else { - let session = PeerSession(peerID: newPeerID, nickname: binding.nickname) - self.peerSessions[newPeerID] = session - } - - // Notify about the change if it's a rotation - if let oldID = oldPeerID { - // Clear the old session instead of migrating it - // This ensures both peers do a fresh handshake after ID rotation - self.cleanupPeerCryptoState(oldID) - self.handshakeCoordinator.resetHandshakeState(for: newPeerID) - - // Log the peer ID rotation - SecureLogger.log("Cleared session for peer ID rotation: \(oldID) -> \(newPeerID), will establish fresh handshake", - category: SecureLogger.handshake, level: .info) - - self.notifyPeerIDChange(oldPeerID: oldID, newPeerID: newPeerID, fingerprint: fingerprint) - - // Remove the old peer session immediately to prevent "Unknown" peers - self.peerSessions.removeValue(forKey: oldID) - - // Lazy handshake: No longer initiate handshake on peer ID rotation - // Handshake will be initiated when first private message is sent - } - } - } - - // Public method to get current peer ID for a fingerprint - func getCurrentPeerIDForFingerprint(_ fingerprint: String) -> String? { - return collectionsQueue.sync { - return fingerprintToPeerID[fingerprint] - } - } - - // Public method to get all current peer IDs for known fingerprints - func getCurrentPeerIDs() -> [String: String] { - return collectionsQueue.sync { - return fingerprintToPeerID - } - } - - // Notify delegate when peer ID changes - private func notifyPeerIDChange(oldPeerID: String, newPeerID: String, fingerprint: String) { - DispatchQueue.main.async { [weak self] in - // Remove old peer ID from active peers and announcedPeers - self?.collectionsQueue.sync(flags: .barrier) { - if let session = self?.peerSessions[oldPeerID] { - session.isActivePeer = false - } - // Don't pre-insert the new peer ID - let the announce packet handle it - // This ensures the connect message logic works properly - } - - // Update PeerSession for old peer ID - if let session = self?.peerSessions[oldPeerID] { - session.hasReceivedAnnounce = false - } - - // Update peer list - self?.notifyPeerListUpdate(immediate: true) - - // Don't send disconnect/connect messages for peer ID rotation - // The peer didn't actually disconnect, they just rotated their ID - // This prevents confusing messages like "3a7e1c2c0d8943b9 disconnected" - - // Instead, notify the delegate about the peer ID change if needed - // (Could add a new delegate method for this in the future) - } - } - - // MARK: - Peer Connection Management - - func getCachedPublicKey(for peerID: String) -> Data? { - return collectionsQueue.sync { - // Check if cache entry exists and is not expired - if let timestamp = identityCacheTimestamps[peerID], - Date().timeIntervalSince(timestamp) < identityCacheTTL { - return peerPublicKeyCache[peerID] - } - return nil - } - } - - func getCachedSigningKey(for peerID: String) -> Data? { - return collectionsQueue.sync { - // Check if cache entry exists and is not expired - if let timestamp = identityCacheTimestamps[peerID], - Date().timeIntervalSince(timestamp) < identityCacheTTL { - return peerSigningKeyCache[peerID] - } - return nil - } - } - - private func updatePeerConnectionState(_ peerID: String, state: PeerConnectionState) { - connectionStateQueue.async(flags: .barrier) { [weak self] in - guard let self = self else { return } - - let previousState = self.peerConnectionStates[peerID] - self.peerConnectionStates[peerID] = state - - SecureLogger.log("Peer \(peerID) connection state: \(previousState?.description ?? "nil") -> \(state)", - category: SecureLogger.session, level: .debug) - - // Update PeerSession based on authentication state - self.collectionsQueue.async(flags: .barrier) { - switch state { - case .authenticated: - if self.peerSessions[peerID]?.isActivePeer != true { - // Update PeerSession - if let session = self.peerSessions[peerID] { - session.isActivePeer = true - } - SecureLogger.log("Marked \(peerID) as active (authenticated)", - category: SecureLogger.session, level: .info) - - // Update PeerSession authentication state - if let session = self.peerSessions[peerID] { - session.updateAuthenticationState(authenticated: true, noiseSession: true) - } else { - let nickname = self.getBestAvailableNickname(for: peerID) - let session = PeerSession(peerID: peerID, nickname: nickname) - session.updateAuthenticationState(authenticated: true, noiseSession: true) - self.peerSessions[peerID] = session - } - - } - case .disconnected: - if self.peerSessions[peerID]?.isActivePeer == true { - // Update PeerSession - if let session = self.peerSessions[peerID] { - session.isActivePeer = false - } - SecureLogger.log("Marked \(peerID) as inactive (disconnected)", - category: SecureLogger.session, level: .info) - } - - // Update PeerSession to disconnected state - if let session = self.peerSessions[peerID] { - session.updateAuthenticationState(authenticated: false, noiseSession: false) - session.updateBluetoothConnection(peripheral: nil, characteristic: nil) - session.isConnected = false - session.isActivePeer = false - // Keep the session but mark it as disconnected - // This allows us to detect reconnections - SecureLogger.log("Marked \(peerID) (\(session.nickname)) as disconnected in peerSessions", - category: SecureLogger.session, level: .info) - } - - // Note: PeerSession is already updated in the disconnect case above - // Clean up old mappings - default: - break - } - - // Always notify peer list update when connection state changes - DispatchQueue.main.async { - self.notifyPeerListUpdate(immediate: true) - } - } - } - } - - // Get peer connection state - func getPeerConnectionState(_ peerID: String) -> PeerConnectionState { - return connectionStateQueue.sync { - peerConnectionStates[peerID] ?? .disconnected - } - } - - // MARK: - Peer ID Rotation - - private func generateNewPeerID() -> String { - // Generate 8 random bytes (64 bits) for strong collision resistance - var randomBytes = [UInt8](repeating: 0, count: 8) - let result = SecRandomCopyBytes(kSecRandomDefault, 8, &randomBytes) - - // If SecRandomCopyBytes fails, use alternative randomization - if result != errSecSuccess { - for i in 0..<8 { - randomBytes[i] = UInt8.random(in: 0...255) - } - } - - // Add timestamp entropy to ensure uniqueness - // Use lower 32 bits of timestamp in milliseconds to avoid overflow - let timestampMs = UInt64(Date().timeIntervalSince1970 * 1000) - let timestamp = UInt32(timestampMs & 0xFFFFFFFF) - randomBytes[4] = UInt8((timestamp >> 24) & 0xFF) - randomBytes[5] = UInt8((timestamp >> 16) & 0xFF) - randomBytes[6] = UInt8((timestamp >> 8) & 0xFF) - randomBytes[7] = UInt8(timestamp & 0xFF) - - return randomBytes.map { String(format: "%02x", $0) }.joined() - } - - func rotatePeerID() { - guard !rotationLocked else { - // Schedule rotation for later - scheduleRotation(delay: 30.0) - return - } - - collectionsQueue.async(flags: .barrier) { [weak self] in - guard let self = self else { return } - - // Save current peer ID - let oldID = self.myPeerID - self.rotationTimestamp = Date() - - // Generate new peer ID - self.myPeerID = self.generateNewPeerID() - - SecureLogger.log("Peer ID rotated from \(oldID) to \(self.myPeerID)", category: SecureLogger.security, level: .info) - - // Update advertising with new peer ID - DispatchQueue.main.async { [weak self] in - self?.updateAdvertisement() - } - - // Send identity announcement with new peer ID - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.sendNoiseIdentityAnnounce() - } - - // Schedule next rotation - self.scheduleNextRotation() - } - } - - private func scheduleRotation(delay: TimeInterval) { - DispatchQueue.main.async { [weak self] in - self?.rotationTimer?.invalidate() - self?.rotationTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in - self?.rotatePeerID() - } - } - } - - private func scheduleNextRotation() { - // Base interval: 1-6 hours - let baseInterval = TimeInterval.random(in: 3600...21600) - - // Add jitter: ±30 minutes - let jitter = TimeInterval.random(in: -1800...1800) - - // Additional random delay to prevent synchronization - let networkDelay = TimeInterval.random(in: 0...300) // 0-5 minutes - - let nextRotation = baseInterval + jitter + networkDelay - - scheduleRotation(delay: nextRotation) - } - - private func updateAdvertisement() { - guard isAdvertising else { return } - - peripheralManager?.stopAdvertising() - - // Update advertisement data with new peer ID - advertisementData = [ - CBAdvertisementDataServiceUUIDsKey: [BluetoothMeshService.serviceUUID], - CBAdvertisementDataLocalNameKey: myPeerID - ] - - peripheralManager?.startAdvertising(advertisementData) - } - - func lockRotation() { - rotationLocked = true - } - - func unlockRotation() { - rotationLocked = false - } - - // MARK: - Initialization - - override init() { - // Generate ephemeral peer ID for each session to prevent tracking - self.myPeerID = "" - super.init() - self.myPeerID = generateNewPeerID() - - // Set up queue-specific key for deadlock prevention - collectionsQueue.setSpecific(key: collectionsQueueKey, value: ()) - - // Initialize Bluetooth managers with state restoration - centralManager = CBCentralManager( - delegate: self, - queue: nil, - options: [CBCentralManagerOptionRestoreIdentifierKey: Self.centralManagerRestorationID] - ) - peripheralManager = CBPeripheralManager( - delegate: self, - queue: nil, - options: [CBPeripheralManagerOptionRestoreIdentifierKey: Self.peripheralManagerRestorationID] - ) - - // Setup app state notifications - setupAppStateNotifications() - - // Setup consolidated timers for better performance - setupConsolidatedTimers() - - /* OLD TIMER SETUP - REPLACED BY CONSOLIDATED TIMERS - bloomFilterResetTimer = Timer.scheduledTimer(withTimeInterval: 300.0, repeats: true) { [weak self] _ in - self?.messageQueue.async(flags: .barrier) { - guard let self = self else { return } - - // Adapt Bloom filter size based on network size - let networkSize = self.estimatedNetworkSize - self.messageBloomFilter = OptimizedBloomFilter.adaptive(for: networkSize) - - // Clean up old processed messages (keep last 10 minutes of messages) - self.processedMessagesLock.lock() - let cutoffTime = Date().addingTimeInterval(-600) // 10 minutes ago - let originalCount = self.processedMessages.count - self.processedMessages = self.processedMessages.filter { _, state in - state.firstSeen > cutoffTime - } - - // Also enforce max size limit during cleanup - if self.processedMessages.count > self.maxProcessedMessages { - let targetSize = Int(Double(self.maxProcessedMessages) * 0.8) - let sortedByFirstSeen = self.processedMessages.sorted { $0.value.firstSeen < $1.value.firstSeen } - let toKeep = Array(sortedByFirstSeen.suffix(targetSize)) - self.processedMessages = Dictionary(uniqueKeysWithValues: toKeep) - } - - let removedCount = originalCount - self.processedMessages.count - self.processedMessagesLock.unlock() - - if removedCount > 0 { - SecureLogger.log("🧹 Cleaned up \(removedCount) old processed messages (kept \(self.processedMessages.count) from last 10 minutes)", - category: SecureLogger.session, level: .debug) - } - } - } - - // Start stale peer cleanup timer (every 30 seconds) - cleanupTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in - self?.cleanupStalePeers() - } - - // Start more aggressive cleanup for Unknown peers (every 2 seconds) - Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in - self?.cleanupUnknownPeers() - } - - // Start ACK timeout checking timer (every 2 seconds for timely retries) - Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in - self?.checkAckTimeouts() - } - - // Start peer availability checking timer (every 15 seconds) - availabilityCheckTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) { [weak self] _ in - self?.checkPeerAvailability() - } - - // Start write queue cleanup timer (every 30 seconds) - writeQueueTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in - self?.cleanExpiredWriteQueues() - } - - // Start identity cache cleanup timer (every hour) - Timer.scheduledTimer(withTimeInterval: 3600.0, repeats: true) { [weak self] _ in - self?.cleanExpiredIdentityCache() - } - - // Start memory cleanup timer (every minute) - startMemoryCleanupTimer() - - // Start connection keep-alive timer to prevent iOS BLE timeouts - connectionKeepAliveTimer = Timer.scheduledTimer(withTimeInterval: keepAliveInterval, repeats: true) { [weak self] _ in - self?.sendKeepAlivePings() - } - - // Clean up stale handshake states periodically - Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in - guard let self = self else { return } - - // Clean up expired dedup entries - self.cleanupExpiredDedupEntries() - - // Clean up stale handshakes - let stalePeerIDs = self.handshakeCoordinator.cleanupStaleHandshakes() - if !stalePeerIDs.isEmpty { - for peerID in stalePeerIDs { - // Also remove from noise service - self.cleanupPeerCryptoState(peerID) - SecureLogger.log("Cleaned up stale handshake for peer: \(peerID)", - category: SecureLogger.handshake, level: .info) - } - } - - #if DEBUG - self.handshakeCoordinator.logHandshakeStates() - #endif - } - END OF OLD TIMER SETUP */ - - // Schedule first peer ID rotation - scheduleNextRotation() - - // Setup noise callbacks - noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in - guard let self = self else { return } - - // Store fingerprint in PeerSession for fast lookups - self.collectionsQueue.async(flags: .barrier) { - if let session = self.peerSessions[peerID] { - session.fingerprint = fingerprint - session.updateAuthenticationState(authenticated: true, noiseSession: true) - } else { - let nickname = self.getBestAvailableNickname(for: peerID) - let session = PeerSession(peerID: peerID, nickname: nickname) - session.fingerprint = fingerprint - session.updateAuthenticationState(authenticated: true, noiseSession: true) - self.peerSessions[peerID] = session - } - } - - // Get peer's public key data from noise service - if let publicKeyData = self.noiseService.getPeerPublicKeyData(peerID) { - // Register with ChatViewModel for verification tracking - DispatchQueue.main.async { - (self.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: peerID, publicKeyData: publicKeyData) - - // Force UI to update encryption status for this specific peer - (self.delegate as? ChatViewModel)?.updateEncryptionStatusForPeer(peerID) - } - } - - // Send regular announce packet when authenticated to trigger connect message - // This covers the case where we're the responder in the handshake - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - self?.sendAnnouncementToPeer(peerID) - } - } - - // Register for app termination notifications - #if os(macOS) - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillTerminate), - name: NSApplication.willTerminateNotification, - object: nil - ) - #else - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillTerminate), - name: UIApplication.willTerminateNotification, - object: nil - ) - #endif - } - - // MARK: - Deinitialization and Cleanup - - deinit { - cleanup() - - // Invalidate consolidated timers - highFrequencyTimer?.invalidate() - mediumFrequencyTimer?.invalidate() - lowFrequencyTimer?.invalidate() - - // Invalidate other timers - scanDutyCycleTimer?.invalidate() - batteryMonitorTimer?.invalidate() - bloomFilterResetTimer?.invalidate() - cleanupTimer?.invalidate() - rotationTimer?.invalidate() - memoryCleanupTimer?.invalidate() - connectionKeepAliveTimer?.invalidate() - availabilityCheckTimer?.invalidate() - NotificationCenter.default.removeObserver(self) - } - - @objc private func appWillTerminate() { - cleanup() - } - - // MARK: - Background Task Management - - #if os(iOS) - private func beginBackgroundTask() -> UIBackgroundTaskIdentifier { - return UIApplication.shared.beginBackgroundTask { [weak self] in - self?.endBackgroundTask(self?.backgroundTask ?? .invalid) - } - } - - private func endBackgroundTask(_ taskID: UIBackgroundTaskIdentifier) { - if taskID != .invalid { - UIApplication.shared.endBackgroundTask(taskID) - } - } - - private func beginScanBackgroundTask() { - guard scanBackgroundTask == .invalid else { return } - - scanBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "BluetoothScan") { [weak self] in - self?.endScanBackgroundTask() - } - } - - private func endScanBackgroundTask() { - if scanBackgroundTask != .invalid { - UIApplication.shared.endBackgroundTask(scanBackgroundTask) - scanBackgroundTask = .invalid - } - } - - private func beginAdvertiseBackgroundTask() { - guard advertiseBackgroundTask == .invalid else { return } - - advertiseBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "BluetoothAdvertise") { [weak self] in - self?.endAdvertiseBackgroundTask() - } - } - - private func endAdvertiseBackgroundTask() { - if advertiseBackgroundTask != .invalid { - UIApplication.shared.endBackgroundTask(advertiseBackgroundTask) - advertiseBackgroundTask = .invalid - } - } - #endif - - // MARK: - App State Management - - private func setupAppStateNotifications() { - #if os(iOS) - NotificationCenter.default.addObserver( - self, - selector: #selector(appDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillResignActive), - name: UIApplication.willResignActiveNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(appDidEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillEnterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil - ) - #elseif os(macOS) - NotificationCenter.default.addObserver( - self, - selector: #selector(appDidBecomeActive), - name: NSApplication.didBecomeActiveNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillResignActive), - name: NSApplication.willResignActiveNotification, - object: nil - ) - #endif - } - - @objc private func appDidBecomeActive() { - isAppInForeground = true - // App became active - } - - @objc private func appWillResignActive() { - isAppInForeground = false - // App will resign active - } - - @objc private func appDidEnterBackground() { - isAppInForeground = false - SecureLogger.log("[APP-STATE] App entered background - switching to background scanning mode", level: .info) - - #if os(iOS) - // Begin background tasks for critical operations - beginScanBackgroundTask() - beginAdvertiseBackgroundTask() - #endif - - // Switch to background scanning mode (continuous with battery-efficient options) - switchToBackgroundScanning() - - // Update advertising for background mode - updateAdvertisingForBackgroundMode() - } - - @objc private func appWillEnterForeground() { - isAppInForeground = true - SecureLogger.log("[APP-STATE] App entering foreground - switching to foreground scanning mode", level: .info) - - #if os(iOS) - // End background tasks as app is coming to foreground - endScanBackgroundTask() - endAdvertiseBackgroundTask() - #endif - - // Switch to foreground scanning mode (with duty cycling) - switchToForegroundScanning() - - // Update advertising for foreground mode - updateAdvertisingForForegroundMode() - } - - private func cleanup() { - // Send leave announcement before disconnecting - sendLeaveAnnouncement() - - // Give the leave message time to send - Thread.sleep(forTimeInterval: 0.2) - - // First, disconnect all peripherals which will trigger disconnect delegates - for (_, peripheral) in connectedPeripherals { - centralManager?.cancelPeripheralConnection(peripheral) - } - - // Stop advertising - if peripheralManager?.isAdvertising == true { - peripheralManager?.stopAdvertising() - } - - // Stop scanning - centralManager?.stopScan() - - // Remove all services - this will disconnect any connected centrals - if peripheralManager?.state == .poweredOn { - peripheralManager?.removeAllServices() - } - - // Clear all tracking - connectedPeripherals.removeAll() - subscribedCentrals.removeAll() - collectionsQueue.sync(flags: .barrier) { - // Clear isActivePeer flag for all sessions - for session in self.peerSessions.values { - session.isActivePeer = false - } - } - // Note: PeerSession hasReceivedAnnounce states are cleared when sessions are removed - // For normal disconnect, respect the timing - networkBecameEmptyTime = Date() - - // Clear announcement tracking in PeerSessions - collectionsQueue.sync(flags: .barrier) { - for session in self.peerSessions.values { - session.hasAnnounced = false - } - } - - // Clear last seen timestamps - peerLastSeenTimestamps.removeAll() - - // Clear all encryption queues - encryptionQueuesLock.lock() - peerEncryptionQueues.removeAll() - encryptionQueuesLock.unlock() - - // Clear peer tracking - // Time tracking removed - now in PeerSession - - } - - // MARK: - Service Management - - /// Starts the Bluetooth mesh networking services. - /// This initializes both central (scanning) and peripheral (advertising) modes, - /// enabling the device to both discover peers and be discovered. - /// Call this method after setting the delegate and localUserID. - func startServices() { - // Clean up any stale/ghost sessions from previous runs - cleanupStaleSessions() - cleanupUnknownPeers() - - // Report initial Bluetooth state to delegate - if let chatViewModel = delegate as? ChatViewModel { - // Use the central manager state as primary indicator - let currentState = centralManager?.state ?? .unknown - Task { @MainActor in - chatViewModel.updateBluetoothState(currentState) - } - } - - // Starting services - // Start both central and peripheral services - if centralManager?.state == .poweredOn { - startScanning() - } - if peripheralManager?.state == .poweredOn { - setupPeripheral() - startAdvertising() - } - - // Send initial announces after services are ready - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - self?.sendBroadcastAnnounce() - } - - // Setup battery optimizer - setupBatteryOptimizer() - } - - // MARK: - Consolidated Timer Management - - private func setupConsolidatedTimers() { - // High-frequency timer (2s) - critical real-time tasks - highFrequencyTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in - guard let self = self else { return } - - // Critical real-time tasks that need frequent checking - self.cleanupUnknownPeers() - self.checkAckTimeouts() - } - - // Medium-frequency timer (20s) - regular maintenance - mediumFrequencyTimer = Timer.scheduledTimer(withTimeInterval: 20.0, repeats: true) { [weak self] _ in - guard let self = self else { return } - self.mediumTimerTickCount += 1 - - // Every 20s: Connection keep-alive - self.sendKeepAlivePings() - - // Every 20s: Peer availability check - self.checkPeerAvailability() - - // Every 40s (every 2 ticks): Stale peer cleanup & write queue cleanup - if self.mediumTimerTickCount % 2 == 0 { - self.cleanupStalePeers() - self.cleanExpiredWriteQueues() - } - } - - // Low-frequency timer (5min) - cleanup and optimization - lowFrequencyTimer = Timer.scheduledTimer(withTimeInterval: 300.0, repeats: true) { [weak self] _ in - guard let self = self else { return } - self.lowTimerTickCount += 1 - - // Every 5min: Bloom filter reset and processed message cleanup - self.messageQueue.async(flags: .barrier) { [weak self] in - guard let self = self else { return } - - // Adapt Bloom filter size based on network size - let networkSize = self.estimatedNetworkSize - self.messageBloomFilter = OptimizedBloomFilter.adaptive(for: networkSize) - - // Clean up old processed messages (keep last 10 minutes of messages) - self.processedMessagesLock.lock() - let cutoffTime = Date().addingTimeInterval(-600) // 10 minutes ago - let originalCount = self.processedMessages.count - self.processedMessages = self.processedMessages.filter { _, state in - state.firstSeen > cutoffTime - } - - // Also enforce max size limit during cleanup - if self.processedMessages.count > self.maxProcessedMessages { - let targetSize = Int(Double(self.maxProcessedMessages) * 0.8) - let sortedByFirstSeen = self.processedMessages.sorted { $0.value.firstSeen < $1.value.firstSeen } - let toKeep = Array(sortedByFirstSeen.suffix(targetSize)) - self.processedMessages = Dictionary(uniqueKeysWithValues: toKeep) - } - - let removedCount = originalCount - self.processedMessages.count - self.processedMessagesLock.unlock() - - if removedCount > 0 { - SecureLogger.log("🧹 Cleaned up \(removedCount) old processed messages (kept \(self.processedMessages.count) from last 10 minutes)", - category: SecureLogger.session, level: .debug) - } - } - - // Every 5min: Memory cleanup - self.performMemoryCleanup() - - // Every 10min (every 2 ticks): Dedup cleanup - if self.lowTimerTickCount % 2 == 0 { - self.cleanupExpiredDedupEntries() - - // Clean up stale handshakes - let stalePeerIDs = self.handshakeCoordinator.cleanupStaleHandshakes() - if !stalePeerIDs.isEmpty { - for peerID in stalePeerIDs { - // Also remove from noise service - self.cleanupPeerCryptoState(peerID) - SecureLogger.log("Cleaned up stale handshake for peer: \(peerID)", - category: SecureLogger.handshake, level: .info) - } - } - } - - // Every 60min (every 12 ticks): Identity cache cleanup - if self.lowTimerTickCount % 12 == 0 { - self.cleanExpiredIdentityCache() - } - } - } - - // MARK: - Message Sending - - func sendBroadcastAnnounce() { - guard let vm = delegate as? ChatViewModel else { return } - - // Check for duplicate suppression - let contentHash = vm.nickname.hashValue - if shouldSuppressProtocolMessage(to: "broadcast", type: .announce, contentHash: contentHash) { - return - } - - let announcePacket = BitchatPacket( - type: MessageType.announce.rawValue, - ttl: 3, // Increase TTL so announce reaches all peers - senderID: myPeerID, - payload: Data(vm.nickname.utf8) ) - - - // Single send with smart collision avoidance - let initialDelay = self.smartCollisionAvoidanceDelay(baseDelay: self.randomDelay()) - DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) { [weak self] in - self?.broadcastPacket(announcePacket) - // Record that we sent this message - self?.recordProtocolMessageSent(to: "broadcast", type: .announce, contentHash: contentHash) - - // Don't automatically send identity announcement on startup - // Let it happen naturally when peers connect - } - } - - func startAdvertising() { - guard peripheralManager?.state == .poweredOn else { - return - } - - // Use generic advertising to avoid identification - // No identifying prefixes or app names for activist safety - - // Only use allowed advertisement keys - advertisementData = [ - CBAdvertisementDataServiceUUIDsKey: [BluetoothMeshService.serviceUUID], - // Use only peer ID without any identifying prefix - CBAdvertisementDataLocalNameKey: myPeerID - ] - - isAdvertising = true - peripheralManager?.startAdvertising(advertisementData) - } - - private func updateAdvertisingForBackgroundMode() { - // Check if we should skip advertising in critically low battery - if batteryOptimizer.currentPowerMode == .ultraLowPower && batteryOptimizer.batteryLevel < 0.1 { - // Only skip advertising if battery is critically low (<10%) - if isAdvertising { - peripheralManager?.stopAdvertising() - isAdvertising = false - SecureLogger.log("⚡ Stopped advertising due to critically low battery", level: .info) - } - return - } - - // Continue advertising in background mode with battery considerations - if !isAdvertising && peripheralManager?.state == .poweredOn { - startAdvertising() - SecureLogger.log("[ADVERTISE] Continued advertising in background mode", level: .info) - } - } - - private func updateAdvertisingForForegroundMode() { - // Always start advertising when in foreground if possible - if !isAdvertising && peripheralManager?.state == .poweredOn { - startAdvertising() - SecureLogger.log("[ADVERTISE] Started advertising in foreground mode", level: .info) - } - } - - func startScanning() { - guard centralManager?.state == .poweredOn else { - SecureLogger.log("[WARNING] Cannot start scanning - central manager not powered on", - category: SecureLogger.session, level: .warning) - return - } - - - // Optimize scan options based on foreground/background state - // macOS fix: Always allow duplicates to ensure we catch all advertisements - let scanOptions: [String: Any] = [ - CBCentralManagerScanOptionAllowDuplicatesKey: (isAppInForeground || getPlatformString() == "macOS") ? true : false - ] - - centralManager?.scanForPeripherals( - withServices: [BluetoothMeshService.serviceUUID], - options: scanOptions - ) - - // Update scan parameters based on battery before starting - updateScanParametersForBattery() - - // Implement scan duty cycling for battery efficiency (only in foreground) - if isAppInForeground { - scheduleScanDutyCycle() - } - } - - func triggerRescan() { - guard centralManager?.state == .poweredOn else { return } - - // Rate limit rescans to prevent excessive battery drain - let now = Date() - guard now.timeIntervalSince(lastRescanTime) >= minRescanInterval else { - return - } - lastRescanTime = now - - // Stop and restart scanning to trigger new discoveries - centralManager?.stopScan() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.startScanning() - } - } - - private func scheduleScanDutyCycle() { - guard scanDutyCycleTimer == nil else { return } - - // Start with active scanning - isActivelyScanning = true - - scanDutyCycleTimer = Timer.scheduledTimer(withTimeInterval: activeScanDuration, repeats: true) { [weak self] _ in - guard let self = self else { return } - - if self.isActivelyScanning { - // Pause scanning to save battery - self.centralManager?.stopScan() - self.isActivelyScanning = false - - // Schedule resume - DispatchQueue.main.asyncAfter(deadline: .now() + self.scanPauseDuration) { [weak self] in - guard let self = self else { return } - if self.centralManager?.state == .poweredOn { - self.centralManager?.scanForPeripherals( - withServices: [BluetoothMeshService.serviceUUID], - options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] - ) - self.isActivelyScanning = true - } - } - } - } - } - - private func setupPeripheral() { - let characteristic = CBMutableCharacteristic( - type: BluetoothMeshService.characteristicUUID, - properties: [.read, .write, .writeWithoutResponse, .notify], - value: nil, - permissions: [.readable, .writeable] - ) - - let service = CBMutableService(type: BluetoothMeshService.serviceUUID, primary: true) - service.characteristics = [characteristic] - - peripheralManager?.add(service) - self.characteristic = characteristic - } - - /// Sends a message through the mesh network. - /// - Parameters: - /// - content: The message content to send - /// - mentions: Array of user IDs being mentioned in the message - /// - recipientID: Optional recipient ID for directed messages (nil for broadcast) - /// - messageID: Optional custom message ID (auto-generated if nil) - /// - timestamp: Optional custom timestamp (current time if nil) - /// - Note: Messages are automatically routed through the mesh using TTL-based forwarding - func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) { - // Defensive check for empty content - guard !content.isEmpty else { return } - - #if os(iOS) - let backgroundTask = beginBackgroundTask() - #endif - - messageQueue.async { [weak self] in - guard let self = self else { - #if os(iOS) - self?.endBackgroundTask(backgroundTask) - #endif - return - } - - let nickname = self.delegate as? ChatViewModel - let senderNick = nickname?.nickname ?? self.myPeerID - - let message = BitchatMessage( - id: messageID, - sender: senderNick, - content: content, - timestamp: timestamp ?? Date(), - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: self.myPeerID, - mentions: mentions.isEmpty ? nil : mentions - ) - - if let messageData = message.toBinaryPayload() { - - - // Use unified message type with broadcast recipient - let packet = BitchatPacket( - type: MessageType.message.rawValue, - senderID: Data(hexString: self.myPeerID) ?? Data(), - recipientID: SpecialRecipients.broadcast, // Special broadcast ID - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), // milliseconds - payload: messageData, - signature: nil, - ttl: self.adaptiveTTL ) - - // Track this message to prevent duplicate sends - let msgID = "\(packet.timestamp)-\(self.myPeerID)-\(packet.payload.prefix(32).hashValue)" - - let shouldSend = !self.recentlySentMessages.contains(msgID) - if shouldSend { - self.recentlySentMessages.insert(msgID) - } - - if shouldSend { - // Clean up old entries after 10 seconds - self.messageQueue.asyncAfter(deadline: .now() + 10.0) { [weak self] in - guard let self = self else { return } - self.recentlySentMessages.remove(msgID) - } - - // Single send with smart collision avoidance - let initialDelay = self.smartCollisionAvoidanceDelay(baseDelay: self.randomDelay()) - DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) { [weak self] in - self?.broadcastPacket(packet) - #if os(iOS) - self?.endBackgroundTask(backgroundTask) - #endif - } - } else { - #if os(iOS) - self.endBackgroundTask(backgroundTask) - #endif - } - } - } - } - - - /// Sends an end-to-end encrypted private message to a specific peer. - /// - Parameters: - /// - content: The message content to encrypt and send - /// - recipientPeerID: The peer ID of the recipient - /// - recipientNickname: The nickname of the recipient (for UI display) - /// - messageID: Optional custom message ID (auto-generated if nil) - /// - Note: This method automatically handles Noise handshake if not already established - func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) { - // Defensive checks - guard !content.isEmpty, !recipientPeerID.isEmpty, !recipientNickname.isEmpty else { - return - } - - let msgID = messageID ?? UUID().uuidString - - #if os(iOS) - let backgroundTask = beginBackgroundTask() - #endif - - messageQueue.async { [weak self] in - guard let self = self else { - #if os(iOS) - self?.endBackgroundTask(backgroundTask) - #endif - return - } - - // Check if this is an old peer ID that has rotated - var targetPeerID = recipientPeerID - - // If we have a fingerprint for this peer ID, check if there's a newer peer ID - if let fingerprint = self.collectionsQueue.sync(execute: { self.peerIDToFingerprint[recipientPeerID] }), - let currentPeerID = self.collectionsQueue.sync(execute: { self.fingerprintToPeerID[fingerprint] }), - currentPeerID != recipientPeerID { - // Use the current peer ID instead - targetPeerID = currentPeerID - } - - // Always use Noise encryption - self.sendPrivateMessageViaNoise(content, to: targetPeerID, recipientNickname: recipientNickname, messageID: msgID) - - #if os(iOS) - self.endBackgroundTask(backgroundTask) - #endif - } - } - - func sendDeliveryAck(_ ack: DeliveryAck, to recipientID: String) { - // Use per-peer encryption queue to prevent nonce desynchronization - let encryptionQueue = getEncryptionQueue(for: recipientID) - - encryptionQueue.async { [weak self] in - guard let self = self else { return } - - // Encode the ACK - let ackData = ack.toBinaryData() - - // Check if we have a Noise session with this peer - // Use noiseService directly - if self.noiseService.hasEstablishedSession(with: recipientID) { - // Use Noise encryption - encrypt only the ACK payload directly - do { - // Create a special payload that indicates this is a delivery ACK - // Format: [1 byte type marker] + [ACK JSON data] - var ackPayload = Data() - ackPayload.append(MessageType.deliveryAck.rawValue) // Type marker - ackPayload.append(ackData) // ACK JSON - - // Encrypt only the payload (not a full packet) - let encryptedPayload = try noiseService.encrypt(ackPayload, for: recipientID) - - // Create outer Noise packet with the encrypted payload - let outerPacket = BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: self.myPeerID) ?? Data(), - recipientID: Data(hexString: recipientID) ?? Data(), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encryptedPayload, - signature: nil, - ttl: 3 ) - - // Try direct delivery first for delivery ACKs - if !self.sendDirectToRecipient(outerPacket, recipientPeerID: recipientID) { - // Recipient not directly connected, use selective relay - // Using relay for delivery ACK - self.sendViaSelectiveRelay(outerPacket, recipientPeerID: recipientID) - } - } catch { - SecureLogger.logError(error, context: "Failed to encrypt delivery ACK via Noise for \(recipientID)", category: SecureLogger.encryption) - } - } else { - // Lazy handshake: No session available, drop the ACK - // No session for delivery ACK, dropping - } - } - } - - private func getEncryptionQueue(for peerID: String) -> DispatchQueue { - encryptionQueuesLock.lock() - defer { encryptionQueuesLock.unlock() } - - if let queue = peerEncryptionQueues[peerID] { - return queue - } - - let queue = DispatchQueue(label: "bitchat.encryption.\(peerID)", qos: .userInitiated) - peerEncryptionQueues[peerID] = queue - return queue - } - - private func removeEncryptionQueue(for peerID: String) { - encryptionQueuesLock.lock() - defer { encryptionQueuesLock.unlock() } - - peerEncryptionQueues.removeValue(forKey: peerID) - } - - // Centralized cleanup for peer crypto state - private func cleanupPeerCryptoState(_ peerID: String) { - noiseService.removePeer(peerID) - handshakeCoordinator.resetHandshakeState(for: peerID) - removeEncryptionQueue(for: peerID) - } - - func sendReadReceipt(_ receipt: ReadReceipt, to recipientID: String) { - // Use per-peer encryption queue to prevent nonce desynchronization - let encryptionQueue = getEncryptionQueue(for: recipientID) - - encryptionQueue.async { [weak self] in - guard let self = self else { return } - - // Encode the receipt - let receiptData = receipt.toBinaryData() - - // Check if we have a Noise session with this peer - // Use noiseService directly - if self.noiseService.hasEstablishedSession(with: recipientID) { - // Use Noise encryption - encrypt only the receipt payload directly - do { - // Create a special payload that indicates this is a read receipt - // Format: [1 byte type marker] + [receipt binary data] - var receiptPayload = Data() - receiptPayload.append(MessageType.readReceipt.rawValue) // Type marker - receiptPayload.append(receiptData) // Receipt binary data - - // Encrypt only the payload (not a full packet) - let encryptedPayload = try noiseService.encrypt(receiptPayload, for: recipientID) - - // Create outer Noise packet with the encrypted payload - let outerPacket = BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: self.myPeerID) ?? Data(), - recipientID: Data(hexString: recipientID) ?? Data(), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encryptedPayload, - signature: nil, - ttl: 3 ) - - // Sending encrypted read receipt - - // Try direct delivery first for read receipts - if !self.sendDirectToRecipient(outerPacket, recipientPeerID: recipientID) { - // Recipient not directly connected, use selective relay - SecureLogger.log("Recipient \(recipientID) not directly connected for read receipt, using relay", - category: SecureLogger.session, level: .info) - self.sendViaSelectiveRelay(outerPacket, recipientPeerID: recipientID) - } - } catch { - SecureLogger.logError(error, context: "Failed to encrypt read receipt via Noise for \(recipientID)", category: SecureLogger.encryption) - } - } else { - // Lazy handshake: No session available, drop the read receipt - SecureLogger.log("No Noise session with \(recipientID) for read receipt - dropping (lazy handshake mode)", - category: SecureLogger.noise, level: .info) - } - } - } - - /// Send a favorite/unfavorite notification to a specific peer - func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { - // Create notification payload with Nostr public key - var content = isFavorite ? "SYSTEM:FAVORITED" : "SYSTEM:UNFAVORITED" - - // Add our Nostr public key if we have one - if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() { - // Include our Nostr npub in the message - content += ":" + myNostrIdentity.npub - SecureLogger.log("[NOSTR] Including our Nostr npub in favorite notification: \(myNostrIdentity.npub)", - category: SecureLogger.session, level: .info) - } - - - // Use existing message infrastructure - if let recipientNickname = getPeerNicknames()[peerID] { - sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname) - } else { - SecureLogger.log("[ERROR] Failed to send favorite notification - peer not found", - category: SecureLogger.session, level: .error) - } - } - - private func sendAnnouncementToPeer(_ peerID: String) { - guard let vm = delegate as? ChatViewModel else { return } - - // Check for duplicate suppression - let contentHash = vm.nickname.hashValue - if shouldSuppressProtocolMessage(to: peerID, type: .announce, contentHash: contentHash) { - return - } - - // Always send announce, don't check if already announced - // This ensures peers get our nickname even if they reconnect - - let packet = BitchatPacket( - type: MessageType.announce.rawValue, - ttl: 3, // Allow relay for better reach - senderID: myPeerID, - payload: Data(vm.nickname.utf8) ) - - if let data = packet.toBinaryData() { - // Try both broadcast and targeted send - broadcastPacket(packet) - - // Also try targeted send if we have the peripheral - if let peripheral = connectedPeripherals[peerID], - peripheral.state == .connected, - let characteristic = peripheral.services?.first(where: { $0.uuid == BluetoothMeshService.serviceUUID })?.characteristics?.first(where: { $0.uuid == BluetoothMeshService.characteristicUUID }) { - writeToPeripheral(data, peripheral: peripheral, characteristic: characteristic, peerID: peerID) - } else { - } - } else { - } - - // Record that we sent this message - recordProtocolMessageSent(to: peerID, type: .announce, contentHash: contentHash) - - // Update PeerSession - collectionsQueue.sync(flags: .barrier) { - if let session = self.peerSessions[peerID] { - session.hasAnnounced = true - } else { - // Create session if it doesn't exist - let nickname = self.getBestAvailableNickname(for: peerID) - let session = PeerSession(peerID: peerID, nickname: nickname) - session.hasAnnounced = true - self.peerSessions[peerID] = session - } - } - } - - private func sendLeaveAnnouncement() { - guard let vm = delegate as? ChatViewModel else { return } - - let packet = BitchatPacket( - type: MessageType.leave.rawValue, - ttl: 1, // Don't relay leave messages - senderID: myPeerID, - payload: Data(vm.nickname.utf8) ) - - broadcastPacket(packet) - } - - // Get Noise session state for UI display - func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { - return collectionsQueue.sync { - // First check our tracked state - if let state = noiseSessionStates[peerID] { - return state - } - - // If no tracked state, check if we have an established session - if noiseService.hasEstablishedSession(with: peerID) { - return .established - } - - // Default to none - return .none - } - } - - // Trigger handshake with a peer (for UI) - func triggerHandshake(with peerID: String) { - // UI triggered handshake - - // Check if we already have a session - if noiseService.hasEstablishedSession(with: peerID) { - // Already have session, skipping handshake - return - } - - // Update state to handshakeQueued - collectionsQueue.sync(flags: .barrier) { - noiseSessionStates[peerID] = .handshakeQueued - } - - // Always initiate handshake when triggered by UI - // This ensures immediate handshake when opening PM - initiateNoiseHandshake(with: peerID) - } - - func getPeerNicknames() -> [String: String] { - return collectionsQueue.sync { - var nicknames: [String: String] = [:] - - // Get nicknames from PeerSessions only (including disconnected ones) - // This allows proper nickname resolution for reconnecting peers - for (peerID, session) in self.peerSessions { - // If we have an "Unknown" nickname, try to resolve a better one before returning - if session.nickname == "Unknown" { - let betterNickname = getBestAvailableNickname(for: peerID) - if betterNickname != "Unknown" { - // Update the session with the better nickname - session.nickname = betterNickname - SecureLogger.log("Updated nickname for \(peerID) from 'Unknown' to '\(betterNickname)'", - category: SecureLogger.session, level: .info) - } - } - nicknames[peerID] = session.nickname - } - - return nicknames - } - } - - func isPeerConnected(_ peerID: String) -> Bool { - return collectionsQueue.sync { - guard let session = self.peerSessions[peerID] else { return false } - return session.isConnected && session.isActivePeer - } - } - - func isPeerKnown(_ peerID: String) -> Bool { - return collectionsQueue.sync { - guard let session = self.peerSessions[peerID] else { return false } - return session.hasReceivedAnnounce - } - } - - // MARK: - Consolidated Peer Info Methods - - /// Get comprehensive peer info from PeerSession (replaces multiple dictionary lookups) - func getPeerInfo(_ peerID: String) -> (nickname: String?, isActive: Bool, isAuthenticated: Bool) { - return collectionsQueue.sync { - if let session = peerSessions[peerID] { - return ( - nickname: session.nickname.isEmpty ? nil : session.nickname, - isActive: session.isActivePeer, - isAuthenticated: session.isAuthenticated - ) - } - // No session exists - return ( - nickname: nil, - isActive: false, - isAuthenticated: false - ) - } - } - - /// Get all connected peers with their info - func getAllConnectedPeers() -> [(peerID: String, nickname: String)] { - return collectionsQueue.sync { - var peers: [(String, String)] = [] - - // Get all connected peers from PeerSessions - for (peerID, session) in peerSessions where session.isConnected { - peers.append((peerID, session.nickname)) - } - - return peers - } - } - - // Emergency disconnect for panic situations - func emergencyDisconnectAll() { - SecureLogger.log("Emergency disconnect triggered", category: SecureLogger.security, level: .warning) - - // Stop advertising immediately - if peripheralManager?.isAdvertising == true { - peripheralManager?.stopAdvertising() - } - - // Stop scanning - centralManager?.stopScan() - scanDutyCycleTimer?.invalidate() - scanDutyCycleTimer = nil - - // Disconnect all peripherals - for (peerID, peripheral) in connectedPeripherals { - SecureLogger.log("Emergency disconnect peer: \(peerID)", category: SecureLogger.session, level: .warning) - centralManager?.cancelPeripheralConnection(peripheral) - } - - // Clear all peer data - connectedPeripherals.removeAll() - peripheralCharacteristics.removeAll() - discoveredPeripherals.removeAll() - subscribedCentrals.removeAll() - // Clear all peer sessions on reset - // Clear PeerSession states - collectionsQueue.sync(flags: .barrier) { - self.peerSessions.removeAll() - } - // For emergency/panic, reset immediately - hasNotifiedNetworkAvailable = false - networkBecameEmptyTime = nil - lastNetworkNotificationTime = nil - processedMessagesLock.lock() - processedMessages.removeAll() - processedMessagesLock.unlock() - incomingFragments.removeAll() - - // Clear all encryption queues - encryptionQueuesLock.lock() - peerEncryptionQueues.removeAll() - encryptionQueuesLock.unlock() - fragmentMetadata.removeAll() - - // Cancel all pending relays - pendingRelaysLock.lock() - for (_, relay) in pendingRelays { - relay.cancel() - } - pendingRelays.removeAll() - pendingRelaysLock.unlock() - - // Clear peer tracking - // Time tracking removed - now in PeerSession - - // Clear persistent identity - noiseService.clearPersistentIdentity() - - // Clear all handshake coordinator states - handshakeCoordinator.clearAllHandshakeStates() - - // Clear handshake attempt times - handshakeAttemptTimes.removeAll() - - // Notify UI that all peers are disconnected - DispatchQueue.main.async { [weak self] in - self?.delegate?.didUpdatePeerList([]) - } - - // Restart services after a short delay to create new identity - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.restartAfterPanic() - } - } - - private func restartAfterPanic() { - SecureLogger.log("Restarting mesh services after panic mode", category: SecureLogger.session, level: .info) - - // Regenerate peer ID with new identity - myPeerID = generateNewPeerID() - - // Reset identity tracking since we have a new identity - peerIDToFingerprint.removeAll() - fingerprintToPeerID.removeAll() - peerIdentityBindings.removeAll() - - // Reset rotation tracking - rotationTimestamp = nil - rotationLocked = false - - // Restart advertising if peripheral is powered on - if peripheralManager?.state == .poweredOn { - setupPeripheral() - startAdvertising() - - // Send announce after restart with new identity - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.sendBroadcastAnnounce() - } - } - - // Restart scanning if central is powered on - if centralManager?.state == .poweredOn { - startScanning() - } - - SecureLogger.log("Mesh services restarted with new peer ID: \(myPeerID)", category: SecureLogger.session, level: .info) - } - - private func getAllConnectedPeerIDs() -> [String] { - // Return all valid active peers - let peersCopy = collectionsQueue.sync { - return Array(self.peerSessions.compactMap { (peerID, session) in - session.isActivePeer ? peerID : nil - }) - } - - - let validPeers = peersCopy.filter { peerID in - // Ensure peerID is valid and not self - let isEmpty = peerID.isEmpty - let isUnknown = peerID == "unknown" - let isSelf = peerID == self.myPeerID - - return !isEmpty && !isUnknown && !isSelf - } - - let result = Array(validPeers).sorted() - return result - } - - // Debounced peer list update notification - private func notifyPeerListUpdate(immediate: Bool = false) { - if immediate { - // For initial connections, update immediately - let connectedPeerIDs = self.getAllConnectedPeerIDs() - - DispatchQueue.main.async { - self.delegate?.didUpdatePeerList(connectedPeerIDs) - } - } else { - // Must schedule timer on main thread - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - // Cancel any pending update - self.peerListUpdateTimer?.invalidate() - - // Schedule a new update after debounce interval - self.peerListUpdateTimer = Timer.scheduledTimer(withTimeInterval: self.peerListUpdateDebounceInterval, repeats: false) { [weak self] _ in - guard let self = self else { return } - - let connectedPeerIDs = self.getAllConnectedPeerIDs() - - self.delegate?.didUpdatePeerList(connectedPeerIDs) - } - } - } - } - - // MARK: - Protocol Message Deduplication - - // Check if a protocol message should be suppressed as duplicate - private func shouldSuppressProtocolMessage(to peerID: String, type: MessageType, contentHash: Int? = nil) -> Bool { - let key = DedupKey(peerID: peerID, messageType: type, contentHash: contentHash) - - // Check if we have a recent entry - if let entry = protocolMessageDedup[key], !isExpired(entry) { - return true - } - - return false - } - - // Record that a protocol message was sent - private func recordProtocolMessageSent(to peerID: String, type: MessageType, contentHash: Int? = nil) { - guard let duration = dedupDurations[type] else { return } - - let key = DedupKey(peerID: peerID, messageType: type, contentHash: contentHash) - let now = Date() - let entry = DedupEntry(sentAt: now, expiresAt: now.addingTimeInterval(duration)) - - protocolMessageDedup[key] = entry - } - - // Check if a dedup entry is expired - private func isExpired(_ entry: DedupEntry) -> Bool { - return Date() > entry.expiresAt - } - - // Clean up expired dedup entries - private func cleanupExpiredDedupEntries() { - let expiredKeys = protocolMessageDedup.compactMap { (key, entry) in - isExpired(entry) ? key : nil - } - - if !expiredKeys.isEmpty { - for key in expiredKeys { - protocolMessageDedup.removeValue(forKey: key) - } - SecureLogger.log("🗑️ Cleaned up \(expiredKeys.count) expired dedup entries", - category: SecureLogger.session, level: .debug) - } - } - - // Clean up stale peers that haven't been seen in a while - private func cleanupStalePeers() { - // First clean up stale/ghost sessions - cleanupStaleSessions() - - let staleThreshold: TimeInterval = 300.0 // 5 minutes - increased for better network stability - let now = Date() - - // Clean up expired gracefully left peers - let expiredGracefulPeers = gracefulLeaveTimestamps.filter { (_, timestamp) in - now.timeIntervalSince(timestamp) > gracefulLeaveExpirationTime - }.map { $0.key } - - for peerID in expiredGracefulPeers { - gracefullyLeftPeers.remove(peerID) - gracefulLeaveTimestamps.removeValue(forKey: peerID) - SecureLogger.log("Cleaned up expired gracefullyLeft entry for \(peerID)", - category: SecureLogger.session, level: .debug) - } - - // Clean up old disconnect notifications - let oldDisconnectNotifications = recentDisconnectNotifications.filter { (_, timestamp) in - now.timeIntervalSince(timestamp) > 60.0 // Clean up after 1 minute - }.map { $0.key } - - for peerID in oldDisconnectNotifications { - recentDisconnectNotifications.removeValue(forKey: peerID) - } - - // Clean up encryption queues for disconnected peers - encryptionQueuesLock.lock() - let disconnectedPeerQueues = peerEncryptionQueues.filter { peerID, _ in - // Remove queue if peer is not connected - let isConnected = connectedPeripherals[peerID]?.state == .connected - return !isConnected - } - for (peerID, _) in disconnectedPeerQueues { - peerEncryptionQueues.removeValue(forKey: peerID) - } - encryptionQueuesLock.unlock() - - let peersToRemove = collectionsQueue.sync(flags: .barrier) { - let toRemove = self.peerSessions.compactMap { (peerID, session) -> String? in - guard session.isActivePeer else { return nil } - if let lastSeen = peerLastSeenTimestamps.get(peerID) { - return now.timeIntervalSince(lastSeen) > staleThreshold ? peerID : nil - } - return nil // Keep peers we haven't tracked yet - } - - var actuallyRemoved: [String] = [] - - for peerID in toRemove { - // Check if this peer has an active peripheral connection - if let peripheral = connectedPeripherals[peerID], peripheral.state == .connected { - // Skipping removal - still has active connection - // Update last seen time to prevent immediate re-removal - peerLastSeenTimestamps.set(peerID, value: Date()) - continue - } - - let nickname = self.peerSessions[peerID]?.nickname ?? "unknown" - // Mark as inactive in PeerSession - if let session = self.peerSessions[peerID] { - session.isActivePeer = false - } - peerLastSeenTimestamps.remove(peerID) - SecureLogger.log("📴 Removed stale peer from network: \(peerID) (\(nickname))", category: SecureLogger.session, level: .info) - - // Clean up all associated data - connectedPeripherals.removeValue(forKey: peerID) - // Update PeerSession - if let session = self.peerSessions[peerID] { - session.hasReceivedAnnounce = false - session.hasAnnounced = false - } - // hasAnnounced already updated in PeerSession above - // Time tracking removed - now in PeerSession - - actuallyRemoved.append(peerID) - // Removed stale peer - } - return actuallyRemoved - } - - if !peersToRemove.isEmpty { - notifyPeerListUpdate() - - // Mark when network became empty, but don't reset flag immediately - let currentNetworkSize = collectionsQueue.sync { - peerSessions.values.filter { $0.isActivePeer }.count - } - if currentNetworkSize == 0 && networkBecameEmptyTime == nil { - networkBecameEmptyTime = Date() - } - } - - // Check if we should reset the notification flag - if let emptyTime = networkBecameEmptyTime { - let currentNetworkSize = collectionsQueue.sync { - peerSessions.values.filter { $0.isActivePeer }.count - } - if currentNetworkSize == 0 { - // Network is still empty, check if enough time has passed - let timeSinceEmpty = Date().timeIntervalSince(emptyTime) - if timeSinceEmpty >= networkEmptyResetDelay { - // Reset the flag after network has been empty for the delay period - hasNotifiedNetworkAvailable = false - // Keep the empty time set so we don't immediately notify again - } - } else { - // Network is no longer empty, clear the empty time - networkBecameEmptyTime = nil - } - } - } - - - - private func broadcastPacket(_ packet: BitchatPacket) { - // CRITICAL CHECK: Never send unencrypted JSON - if packet.type == MessageType.deliveryAck.rawValue { - // Check if payload looks like JSON - if let jsonCheck = String(data: packet.payload.prefix(1), encoding: .utf8), jsonCheck == "{" { - // Block unencrypted JSON in delivery ACKs - return - } - } - - - guard let data = packet.toBinaryData() else { - // Failed to convert packet - add to retry queue if it's our message - let senderID = packet.senderID.hexEncodedString() - if senderID == self.myPeerID, - packet.type == MessageType.message.rawValue, - let message = BitchatMessage.fromBinaryPayload(packet.payload) { - MessageRetryService.shared.addMessageForRetry( - content: message.content, - mentions: message.mentions, - isPrivate: message.isPrivate, - recipientPeerID: nil, - recipientNickname: message.recipientNickname, - originalMessageID: message.id, - originalTimestamp: message.timestamp - ) - } - return - } - - // Check if fragmentation is needed for large packets - if data.count > 512 && packet.type != MessageType.fragmentStart.rawValue && - packet.type != MessageType.fragmentContinue.rawValue && - packet.type != MessageType.fragmentEnd.rawValue { - sendFragmentedPacket(packet) - return - } - - // Track which peers we've sent to (to avoid duplicates) - var sentToPeers = Set() - - // Send to connected peripherals (as central) - var sentToPeripherals = 0 - - // Log if this is a private message being broadcast - if packet.type == MessageType.noiseEncrypted.rawValue, - let recipientID = packet.recipientID?.hexEncodedString(), - !recipientID.isEmpty { - SecureLogger.log("WARNING: Broadcasting private message intended for \(recipientID) to all peers", - category: SecureLogger.session, level: .warning) - } - - // Broadcasting to connected peripherals - for (peerID, peripheral) in connectedPeripherals { - if let characteristic = peripheralCharacteristics[peripheral] { - // Check if peripheral is connected before writing - if peripheral.state == .connected { - // Additional safety check for characteristic properties - if characteristic.properties.contains(.write) || - characteristic.properties.contains(.writeWithoutResponse) { - // Writing packet to peripheral - writeToPeripheral(data, peripheral: peripheral, characteristic: characteristic, peerID: peerID) - sentToPeripherals += 1 - sentToPeers.insert(peerID) - } - } else { - if let peerID = connectedPeripherals.first(where: { $0.value == peripheral })?.key { - connectedPeripherals.removeValue(forKey: peerID) - peripheralCharacteristics.removeValue(forKey: peripheral) - } - } - } - } - - // Send to subscribed centrals (as peripheral) - but only if we didn't already send via peripheral connections - var sentToCentrals = 0 - if let char = characteristic, !subscribedCentrals.isEmpty && sentToPeripherals == 0 { - // Only send to centrals if we haven't sent via peripheral connections - // This prevents duplicate sends in 2-peer networks where peers connect both ways - // Broadcasting to subscribed centrals - let success = peripheralManager?.updateValue(data, for: char, onSubscribedCentrals: nil) ?? false - if success { - sentToCentrals = subscribedCentrals.count - } - } else if sentToPeripherals > 0 && !subscribedCentrals.isEmpty { - // Skip central broadcast - already sent via peripherals - } - - // If no peers received the message, add to retry queue ONLY if it's our own message - if sentToPeripherals == 0 && sentToCentrals == 0 { - // Check if this packet originated from us - let senderID = packet.senderID.hexEncodedString() - if senderID == self.myPeerID { - // This is our own message that failed to send - if packet.type == MessageType.message.rawValue, - let message = BitchatMessage.fromBinaryPayload(packet.payload) { - MessageRetryService.shared.addMessageForRetry( - content: message.content, - mentions: message.mentions, - isPrivate: message.isPrivate, - recipientPeerID: nil, - recipientNickname: message.recipientNickname, - originalMessageID: message.id, - originalTimestamp: message.timestamp - ) - } - } - } - } - - private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: String, peripheral: CBPeripheral? = nil, decrypted: Bool = false, fromCentral: CBCentral? = nil) { - messageQueue.async(flags: .barrier) { [weak self] in - guard let self = self else { return } - - // Track that we heard from this peer - let senderID = packet.senderID.hexEncodedString() - if !senderID.isEmpty && senderID != self.myPeerID { - // Update peer availability - self.updatePeerAvailability(senderID) - - // IMPORTANT: Update peripheral mapping for ALL message types including handshakes - // This ensures disconnect messages work even if peer disconnects right after handshake - if let peripheral = peripheral { - let peripheralID = peripheral.identifier.uuidString - - // Check if we need to update the mapping - if peerIDByPeripheralID[peripheralID] != senderID { - peerIDByPeripheralID[peripheralID] = senderID - - // Also ensure connectedPeripherals has the correct mapping - // Remove any temp ID mapping if it exists - let tempIDToRemove = connectedPeripherals.first(where: { $0.value == peripheral && $0.key != senderID })?.key - if let tempID = tempIDToRemove { - SecureLogger.log("Removing temp ID mapping: \(tempID) -> \(peripheralID)", - category: SecureLogger.session, level: .debug) - connectedPeripherals.removeValue(forKey: tempID) - // Also remove any peer session created with temp ID - if peerSessions[tempID] != nil { - SecureLogger.log("Removing temp peer session for \(tempID)", - category: SecureLogger.session, level: .debug) - peerSessions.removeValue(forKey: tempID) - } - } - updatePeripheralConnection(senderID, peripheral: peripheral) - - // Update the peripheral mapping to use the real peer ID - updatePeripheralMapping(peripheralID: peripheralID, peerID: senderID) - } - } - - // Check if this is a reconnection after a long silence - let wasReconnection: Bool - if let session = self.peerSessions[senderID] { - if let lastHeard = session.lastHeardFromPeer { - let timeSinceLastHeard = Date().timeIntervalSince(lastHeard) - wasReconnection = timeSinceLastHeard > 30.0 - } else { - wasReconnection = true - } - session.lastHeardFromPeer = Date() - } else { - // Don't create a session just from hearing a packet - wait for proper connection - wasReconnection = false - } - - // If this is a reconnection, send our identity announcement - if wasReconnection && packet.type != MessageType.noiseIdentityAnnounce.rawValue { - // Detected reconnection, sending identity - DispatchQueue.main.async { [weak self] in - self?.sendNoiseIdentityAnnounce(to: senderID) - } - } - } - - - // Log specific Noise packet types - - guard packet.ttl > 0 else { - return - } - - // Validate packet has payload - guard !packet.payload.isEmpty else { - return - } - - // Update last seen timestamp for this peer - if senderID != "unknown" && senderID != self.myPeerID { - peerLastSeenTimestamps.set(senderID, value: Date()) - } - - // Replay attack protection: Check timestamp is within reasonable window (5 minutes) - let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds - let timeDiff = abs(Int64(currentTime) - Int64(packet.timestamp)) - if timeDiff > 300000 { // 5 minutes in milliseconds - SecureLogger.log("Replay attack detected - timestamp from \(senderID)", category: SecureLogger.security, level: .warning) - SecureLogger.log("Dropped message with stale timestamp. Age: \(timeDiff/1000)s from \(senderID)", category: SecureLogger.security, level: .warning) - return - } - - // Log message type for debugging - let messageTypeName: String - switch MessageType(rawValue: packet.type) { - case .message: - messageTypeName = "MESSAGE" - case .protocolAck: - messageTypeName = "PROTOCOL_ACK" - case .protocolNack: - messageTypeName = "PROTOCOL_NACK" - case .noiseHandshakeInit: - messageTypeName = "NOISE_HANDSHAKE_INIT" - case .noiseHandshakeResp: - messageTypeName = "NOISE_HANDSHAKE_RESP" - case .noiseIdentityAnnounce: - messageTypeName = "NOISE_IDENTITY_ANNOUNCE" - case .noiseEncrypted: - messageTypeName = "NOISE_ENCRYPTED" - case .leave: - messageTypeName = "LEAVE" - case .readReceipt: - messageTypeName = "READ_RECEIPT" - case .systemValidation: - messageTypeName = "SYSTEM_VALIDATION" - case .handshakeRequest: - messageTypeName = "HANDSHAKE_REQUEST" - default: - messageTypeName = "UNKNOWN(\(packet.type))" - } - - // Processing packet - - // Rate limiting check with message type awareness - let isHighPriority = [MessageType.protocolAck.rawValue, - MessageType.protocolNack.rawValue, - MessageType.noiseHandshakeInit.rawValue, - MessageType.noiseHandshakeResp.rawValue, - MessageType.noiseIdentityAnnounce.rawValue, - MessageType.leave.rawValue].contains(packet.type) - - - if senderID != self.myPeerID && isRateLimited(peerID: senderID, messageType: packet.type) { - if !isHighPriority { - SecureLogger.log("RATE_LIMITED: Dropped \(messageTypeName) from \(senderID)", - category: SecureLogger.security, level: .warning) - return - } else { - SecureLogger.log("RATE_LIMITED: Allowing high-priority \(messageTypeName) from \(senderID)", - category: SecureLogger.security, level: .info) - } - } - - // Record message for rate limiting based on type - if senderID != self.myPeerID && !isHighPriority { - recordMessage(from: senderID, messageType: packet.type) - } - - // Content-based duplicate detection using packet ID - let messageID = generatePacketID(for: packet) - - // Check if we've seen this exact message before - processedMessagesLock.lock() - if let existingState = processedMessages[messageID] { - // Update the state - var updatedState = existingState - updatedState.updateSeen() - processedMessages[messageID] = updatedState - processedMessagesLock.unlock() - - // Dropped duplicate message - // Cancel any pending relay for this message - cancelPendingRelay(messageID: messageID) - return - } - processedMessagesLock.unlock() - - // Use bloom filter for efficient duplicate detection - if messageBloomFilter.contains(messageID) { - // Double check with exact set (bloom filter can have false positives) - processedMessagesLock.lock() - let isProcessed = processedMessages[messageID] != nil - processedMessagesLock.unlock() - if !isProcessed { - // Bloom filter false positive - } - } - - // Record this message as processed - messageBloomFilter.insert(messageID) - processedMessagesLock.lock() - processedMessages[messageID] = MessageState( - firstSeen: Date(), - lastSeen: Date(), - seenCount: 1, - relayed: false, - acknowledged: false - ) - - // Prune old entries if needed (more efficient approach) - if processedMessages.count > maxProcessedMessages { - // Remove oldest 20% to avoid frequent pruning - let targetSize = Int(Double(maxProcessedMessages) * 0.8) - let cutoffTime = Date().addingTimeInterval(-3600) // 1 hour ago - - // First try removing old messages - processedMessages = processedMessages.filter { _, state in - state.firstSeen > cutoffTime - } - - // If still too many, remove oldest entries - if processedMessages.count > targetSize { - let sortedByFirstSeen = processedMessages.sorted { $0.value.firstSeen < $1.value.firstSeen } - let toKeep = Array(sortedByFirstSeen.suffix(targetSize)) - processedMessages = Dictionary(uniqueKeysWithValues: toKeep) - } - } - processedMessagesLock.unlock() - - - // Log statistics periodically - if messageBloomFilter.insertCount % 100 == 0 { - _ = messageBloomFilter.estimatedFalsePositiveRate - } - - // Bloom filter will be reset by timer, processedMessages is now bounded - - - - // Note: We'll decode messages in the switch statement below, not here - - switch MessageType(rawValue: packet.type) { - case .message: - // Unified message handler for both broadcast and private messages - // Convert binary senderID back to hex string - let senderID = packet.senderID.hexEncodedString() - if senderID.isEmpty { - return - } - - - // Ignore our own messages - if senderID == myPeerID { - return - } - - // Check if this is a broadcast or private message - if let recipientID = packet.recipientID { - if recipientID == SpecialRecipients.broadcast { - // BROADCAST MESSAGE - - // No signature verification - broadcasts are not authenticated - - // Parse broadcast message (not encrypted) - if let message = BitchatMessage.fromBinaryPayload(packet.payload) { - - // Store nickname mapping - collectionsQueue.sync(flags: .barrier) { - // Update PeerSession - if let session = self.peerSessions[senderID] { - session.nickname = message.sender - } else { - let session = PeerSession(peerID: senderID, nickname: message.sender) - self.peerSessions[senderID] = session - } - } - - let finalContent = message.content - - let messageWithPeerID = BitchatMessage( - id: message.id, // Preserve the original message ID - sender: message.sender, - content: finalContent, - timestamp: message.timestamp, - isRelay: message.isRelay, - originalSender: message.originalSender, - isPrivate: false, - recipientNickname: nil, - senderPeerID: senderID, - mentions: message.mentions - ) - - // Track last message time from this peer - let peerID = packet.senderID.hexEncodedString() - self.lastMessageFromPeer.set(peerID, value: Date()) - - DispatchQueue.main.async { - self.delegate?.didReceiveMessage(messageWithPeerID) - } - - } - - // Relay broadcast messages - var relayPacket = packet - relayPacket.ttl -= 1 - if relayPacket.ttl > 0 { - // Relaying broadcast - // High priority messages relay immediately, others use exponential delay - if self.isHighPriorityMessage(type: relayPacket.type) { - self.broadcastPacket(relayPacket) - } else { - let delay = self.exponentialRelayDelay() - self.scheduleRelay(relayPacket, messageID: messageID, delay: delay) - } - } - - } else if isPeerIDOurs(recipientID.hexEncodedString()) { - // PRIVATE MESSAGE FOR US - if (decrypted == false) { - //spoofing detected!! - SecureLogger.log("YIKES spoofing detected from \(senderID)", category: SecureLogger.encryption, level: .warning) - return; - } - - - // No signature verification - broadcasts are not authenticated - - // Private messages should only come through Noise now - // If we're getting a private message here, it must already be decrypted from Noise - let decryptedPayload = packet.payload - - // Parse the message - if let message = BitchatMessage.fromBinaryPayload(decryptedPayload) { - - // Check if this is a favorite/unfavorite notification - if message.content.hasPrefix("SYSTEM:FAVORITED") || message.content.hasPrefix("SYSTEM:UNFAVORITED") { - let parts = message.content.split(separator: ":") - let isFavorite = parts.count >= 2 && parts[0] == "SYSTEM" && parts[1] == "FAVORITED" - let action = isFavorite ? "favorited" : "unfavorited" - let nostrNpub = parts.count > 2 ? String(parts[2]) : nil - - SecureLogger.log("[NOTIFICATION] Received \(action) notification from \(senderID)", category: SecureLogger.session, level: .info) - if nostrNpub != nil { - // Peer's Nostr npub recorded - } - - // Handle favorite notification - DispatchQueue.main.async { - Task { @MainActor in - let vm = self.delegate as? ChatViewModel - let nickname = message.sender - - if isFavorite { - vm?.handlePeerFavoritedUs(peerID: senderID, favorited: true, nickname: nickname, nostrNpub: nostrNpub) - } else { - vm?.handlePeerFavoritedUs(peerID: senderID, favorited: false, nickname: nickname, nostrNpub: nostrNpub) - } - - // Send system message to user - let systemMessage = BitchatMessage( - sender: "system", - content: "\(nickname) \(action) you.", - timestamp: Date(), - isRelay: false - ) - self.delegate?.didReceiveMessage(systemMessage) - } - } - return - } - - // Check if we've seen this exact message recently (within 5 seconds) - let messageKey = "\(senderID)-\(message.content)-\(message.timestamp)" - if let lastReceived = self.receivedMessageTimestamps.get(messageKey) { - let timeSinceLastReceived = Date().timeIntervalSince(lastReceived) - if timeSinceLastReceived < 5.0 { - } - } - self.receivedMessageTimestamps.set(messageKey, value: Date()) - - // LRU cache handles cleanup automatically - - collectionsQueue.sync(flags: .barrier) { - if self.peerSessions[senderID] == nil { - let session = PeerSession(peerID: senderID, nickname: message.sender) - self.peerSessions[senderID] = session - } else if self.peerSessions[senderID]?.nickname == "Unknown" { - self.peerSessions[senderID]?.nickname = message.sender - } - - // PeerSession already updated if nickname not set - if let session = self.peerSessions[senderID] { - if session.nickname.isEmpty || session.nickname == "Unknown" { - session.nickname = message.sender - } - } else { - let session = PeerSession(peerID: senderID, nickname: message.sender) - self.peerSessions[senderID] = session - } - } - - let messageWithPeerID = BitchatMessage( - id: message.id, // Preserve the original message ID - sender: message.sender, - content: message.content, - timestamp: message.timestamp, - isRelay: message.isRelay, - originalSender: message.originalSender, - isPrivate: message.isPrivate, - recipientNickname: message.recipientNickname, - senderPeerID: senderID, - mentions: message.mentions, - deliveryStatus: nil // Will be set to .delivered in ChatViewModel - ) - - // Track last message time from this peer - let peerID = packet.senderID.hexEncodedString() - self.lastMessageFromPeer.set(peerID, value: Date()) - - DispatchQueue.main.async { - self.delegate?.didReceiveMessage(messageWithPeerID) - } - - // Generate and send ACK for private messages - let viewModel = self.delegate as? ChatViewModel - let myNickname = viewModel?.nickname ?? self.myPeerID - if let ack = DeliveryTracker.shared.generateAck( - for: messageWithPeerID, - myPeerID: self.myPeerID, - myNickname: myNickname, - hopCount: UInt8(self.maxTTL - packet.ttl) - ) { - self.sendDeliveryAck(ack, to: senderID) - } - } else { - SecureLogger.log("Failed to parse private message from binary, size: \(decryptedPayload.count)", category: SecureLogger.encryption, level: .error) - } - - } else if packet.ttl > 0 { - // RELAY PRIVATE MESSAGE (not for us) - var relayPacket = packet - relayPacket.ttl -= 1 - - - // Use constant relay prob - let relayProb = 1.0 - - // Relay based on probability only - no forced relay for small networks - let shouldRelay = Double.random(in: 0...1) < relayProb - - if shouldRelay { - // High priority messages relay immediately, others use exponential delay - if self.isHighPriorityMessage(type: relayPacket.type) { - self.broadcastPacket(relayPacket) - } else { - let delay = self.exponentialRelayDelay() - self.scheduleRelay(relayPacket, messageID: messageID, delay: delay) - } - } - } else { - // Message has recipient ID but not for us and TTL is 0 - // Message not for us - will be relayed if TTL > 0 - } - } else { - // No recipient ID - this shouldn't happen for messages - SecureLogger.log("Message packet with no recipient ID from \(senderID)", category: SecureLogger.security, level: .warning) - } - - // Note: 0x02 was legacy keyExchange - removed - - case .announce: - if let rawNickname = String(data: packet.payload, encoding: .utf8) { - // Trim whitespace from received nickname - let nickname = rawNickname.trimmingCharacters(in: .whitespacesAndNewlines) - let senderID = packet.senderID.hexEncodedString() - - // Received announce from peer - - // Ignore if it's from ourselves (including previous peer IDs) - if isPeerIDOurs(senderID) { - return - } - - // Check if we've already announced this peer - let isFirstAnnounce = collectionsQueue.sync { - if let session = self.peerSessions[senderID] { - return !session.hasReceivedAnnounce - } else { - return true - } - } - - // Check if this is a reconnection after disconnect - let wasDisconnected = collectionsQueue.sync { - if let session = self.peerSessions[senderID] { - return !session.isConnected && !session.isActivePeer && session.hasReceivedAnnounce - } - return false - } - - // Clean up stale peer IDs with the same nickname - collectionsQueue.sync(flags: .barrier) { - var stalePeerIDs: [String] = [] - for (existingPeerID, existingSession) in self.peerSessions { - if existingSession.nickname == nickname && existingPeerID != senderID { - // Check if this peer was seen very recently (within 10 seconds) - let wasRecentlySeen = self.peerLastSeenTimestamps.get(existingPeerID).map { Date().timeIntervalSince($0) < 10.0 } ?? false - if !wasRecentlySeen { - // Found a stale peer ID with the same nickname - stalePeerIDs.append(existingPeerID) - // Found stale peer ID - } else { - // Peer was seen recently, keeping both - } - } - } - - // Remove stale peer IDs - for stalePeerID in stalePeerIDs { - // Removing stale peer - self.peerSessions.removeValue(forKey: stalePeerID) - - // Mark as inactive in PeerSession - if let session = self.peerSessions[stalePeerID] { - session.isActivePeer = false - } - - // Remove from announced peers - // Update PeerSession for stale peer - if let session = self.peerSessions[stalePeerID] { - session.hasReceivedAnnounce = false - session.hasAnnounced = false - } - - // Clear tracking data - now handled by removing PeerSession - - // Disconnect any peripherals associated with stale ID - if let peripheral = self.connectedPeripherals[stalePeerID] { - self.intentionalDisconnects.insert(peripheral.identifier.uuidString) - self.centralManager?.cancelPeripheralConnection(peripheral) - self.connectedPeripherals.removeValue(forKey: stalePeerID) - self.peripheralCharacteristics.removeValue(forKey: peripheral) - } - - // Remove from last seen timestamps - self.peerLastSeenTimestamps.remove(stalePeerID) - - // No longer tracking key exchanges - } - - // If we had stale peers, notify the UI immediately - if !stalePeerIDs.isEmpty { - DispatchQueue.main.async { [weak self] in - self?.notifyPeerListUpdate(immediate: true) - } - } - - // Only update peer session if we already have one (from a real connection) - // Don't create sessions from relayed announces - if let session = self.peerSessions[senderID] { - session.nickname = nickname - session.hasReceivedAnnounce = true - session.lastSeen = Date() - - // Check if this peer's noise public key has an existing favorite with a different nickname - if let noisePublicKey = Data(hexString: senderID) { - DispatchQueue.main.async { - FavoritesPersistenceService.shared.updateNickname(for: noisePublicKey, newNickname: nickname) - } - } - } - // Note: We'll create the session later if the peer passes the peripheral connection check - } - - // Update peripheral mapping if we have it - // If peripheral is nil (e.g., from relay), try to find it - var peripheralToUpdate = peripheral - if peripheralToUpdate == nil { - // Look for any peripheral that might be this peer - // First check if we already have a mapping for this peer ID - peripheralToUpdate = self.connectedPeripherals[senderID] - - if peripheralToUpdate == nil { - // No peripheral mapping found - - // Try to find an unidentified peripheral that might be this peer - // This handles case where announce is relayed and we need to update peripheral mapping - var unmappedPeripherals: [(String, CBPeripheral)] = [] - for (tempID, peripheral) in self.connectedPeripherals { - // Check if this is a temp ID (UUID format, not a peer ID) - if tempID.count == 36 && tempID.contains("-") { // UUID length with dashes - unmappedPeripherals.append((tempID, peripheral)) - // Found unmapped peripheral - } - } - - // If we have exactly one unmapped peripheral, it's likely this one - if unmappedPeripherals.count == 1 { - let (tempID, peripheral) = unmappedPeripherals[0] - // Mapping peripheral to sender - peripheralToUpdate = peripheral - - // Remove temp mapping and add real mapping - self.connectedPeripherals.removeValue(forKey: tempID) - self.updatePeripheralConnection(senderID, peripheral: peripheral) - - } else if unmappedPeripherals.count > 1 { - // Multiple unmapped peripherals - // TODO: Could use timing heuristics or other methods to match - } - } - } - - if let peripheral = peripheralToUpdate { - let peripheralID = peripheral.identifier.uuidString - - // Update simplified mapping - updatePeripheralMapping(peripheralID: peripheralID, peerID: senderID) - - // Find and remove any temp ID mapping for this peripheral - var tempIDToRemove: String? = nil - for (id, per) in self.connectedPeripherals { - if per == peripheral && id != senderID && id == peripheralID { - tempIDToRemove = id - break - } - } - - if let tempID = tempIDToRemove { - - // IMPORTANT: Mark old peer ID as inactive to prevent duplicates - collectionsQueue.sync(flags: .barrier) { - if let session = self.peerSessions[tempID], session.isActivePeer { - session.isActivePeer = false - } - } - - // Don't notify about disconnect - this is just cleanup of temporary ID - } else { - // Direct mapping peripheral - // No temp ID found, just add the mapping - self.updatePeripheralConnection(senderID, peripheral: peripheral) - self.peerIDByPeripheralID[peripheral.identifier.uuidString] = senderID - - - // If peer was previously relay-connected, update to direct connection - if let session = self.peerSessions[senderID], !session.isConnected && session.hasReceivedAnnounce { - SecureLogger.log("Upgrading peer \(senderID) from relay to direct connection", - category: SecureLogger.session, level: .info) - session.isConnected = true - session.updateBluetoothConnection(peripheral: peripheral, characteristic: nil) - - } - } - } - - // Add to active peers if not already there - if senderID != "unknown" && senderID != self.myPeerID { - // Check for duplicate nicknames and remove old peer IDs - collectionsQueue.sync(flags: .barrier) { - // Find any existing peers with the same nickname - var oldPeerIDsToRemove: [String] = [] - for (existingPeerID, session) in self.peerSessions { - if existingPeerID != senderID && session.isActivePeer { - let existingNickname = session.nickname - if existingNickname == nickname && !existingNickname.isEmpty && existingNickname != "unknown" { - oldPeerIDsToRemove.append(existingPeerID) - } - } - } - - // Remove old peer IDs with same nickname - for oldPeerID in oldPeerIDsToRemove { - // Remove the entire session for duplicate nicknames - self.peerSessions.removeValue(forKey: oldPeerID) - self.connectedPeripherals.removeValue(forKey: oldPeerID) - - // Don't notify about disconnect - this is just cleanup of duplicate - } - } - - var wasUpgradedFromRelay = false - - // Check if we have a direct connection (either as central or peripheral) - let hasPeripheralConnection = self.connectedPeripherals[senderID] != nil || - (peripheral != nil && peripheral?.state == .connected) || - fromCentral != nil // We're the peripheral, sender is central - - let wasInserted = collectionsQueue.sync(flags: .barrier) { - // Final safety check - if senderID == self.myPeerID { - SecureLogger.log("Blocked self from being marked as active", category: SecureLogger.noise, level: .error) - return false - } - - // Check if we should mark as active despite no peripheral - // This can happen during reconnection when announces arrive before peripheral mapping - let shouldMarkActive: Bool - - if hasPeripheralConnection { - shouldMarkActive = true - } else { - // Check for unmapped peripherals - var hasUnmappedPeripheral = false - for (tempID, _) in self.connectedPeripherals { - if tempID.count == 36 && tempID.contains("-") { // UUID format - hasUnmappedPeripheral = true - break - } - } - - if hasUnmappedPeripheral { - shouldMarkActive = true - - // Trigger a rescan to establish peripheral mapping - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.triggerRescan() - } - } else { - SecureLogger.log("[WARNING] Not marking \(senderID) as active - no peripheral connection and no recent activity", - category: SecureLogger.session, level: .warning) - // Still update/create session to track the peer, but don't mark as connected - if let session = self.peerSessions[senderID] { - session.nickname = nickname // Update nickname from announce - session.hasReceivedAnnounce = true - session.lastSeen = Date() - // Don't set isConnected or isActivePeer without peripheral - SecureLogger.log("[RELAY-SESSION] Updated relay-only session for \(senderID) (\(nickname))", - category: SecureLogger.session, level: .info) - } else { - // Create new session but not connected - let session = PeerSession(peerID: senderID, nickname: nickname) - session.hasReceivedAnnounce = true - session.lastSeen = Date() - // Don't set isConnected or isActivePeer without peripheral - self.peerSessions[senderID] = session - SecureLogger.log("[NEW-RELAY] Created new relay-only session for \(senderID) (\(nickname))", - category: SecureLogger.session, level: .info) - } - shouldMarkActive = false - } - } - - if !shouldMarkActive { - return false - } - - let result: Bool - - if let session = self.peerSessions[senderID] { - // Check if we're upgrading from relay-only to direct connection - wasUpgradedFromRelay = !session.isConnected && session.hasReceivedAnnounce - - result = !session.isActivePeer - session.isActivePeer = true - session.isConnected = true - session.nickname = nickname // Update nickname from announce - session.hasReceivedAnnounce = true - session.lastSeen = Date() - if let peripheral = peripheral { - session.updateBluetoothConnection(peripheral: peripheral, characteristic: nil) - } else if fromCentral != nil { - // We're the peripheral, mark as connected even without a peripheral object - session.isConnected = true - } - } else { - // Create new session with the nickname from the announce - let session = PeerSession(peerID: senderID, nickname: nickname) - session.isActivePeer = true - session.isConnected = true - session.hasReceivedAnnounce = true - session.lastSeen = Date() - if let peripheral = peripheral { - session.updateBluetoothConnection(peripheral: peripheral, characteristic: nil) - } else if fromCentral != nil { - // We're the peripheral, mark as connected even without a peripheral object - session.isConnected = true - } - self.peerSessions[senderID] = session - result = true - } - - return result - } - if wasInserted { - SecureLogger.log("[JOINED] Peer joined network: \(senderID) (\(nickname))", category: SecureLogger.session, level: .info) - } - - // Show join message only once per peer connection session - // For direct connections: show on first announce with peripheral - // For relay connections: show on first announce when no peripheral available - let shouldShowConnectMessage = (isFirstAnnounce && wasInserted) || - (wasDisconnected && hasPeripheralConnection) - - - if shouldShowConnectMessage { - if wasUpgradedFromRelay { - SecureLogger.log("[UPGRADE] Peer upgraded from relay to direct: \(senderID) (\(nickname))", - category: SecureLogger.session, level: .info) - } else if wasDisconnected { - SecureLogger.log("[RECONNECT] Peer reconnected: \(senderID) (\(nickname))", - category: SecureLogger.session, level: .info) - } - - // Delay the connect message slightly to allow identity announcement to be processed - // This helps ensure fingerprint mappings are available for nickname resolution - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - self.delegate?.didConnectToPeer(senderID) - } - self.notifyPeerListUpdate(immediate: true) - - // Send network available notification if appropriate - let currentNetworkSize = collectionsQueue.sync { - self.peerSessions.values.filter { $0.isActivePeer }.count - } - if currentNetworkSize > 0 { - // Clear empty time since network is active - networkBecameEmptyTime = nil - - if !hasNotifiedNetworkAvailable { - // Check if enough time has passed since last notification - let now = Date() - var shouldSendNotification = true - - if let lastNotification = lastNetworkNotificationTime { - let timeSinceLastNotification = now.timeIntervalSince(lastNotification) - if timeSinceLastNotification < networkNotificationCooldown { - // Too soon to send another notification - shouldSendNotification = false - } - } - - if shouldSendNotification { - hasNotifiedNetworkAvailable = true - lastNetworkNotificationTime = now - NotificationService.shared.sendNetworkAvailableNotification(peerCount: currentNetworkSize) - } - } - } - - DispatchQueue.main.async { - // Check if this is a favorite peer and send notification - // Note: This might not work immediately if key exchange hasn't happened yet - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self = self else { return } - - // Check if this is a favorite using their public key fingerprint - if let fingerprint = self.getPeerFingerprint(senderID) { - if self.delegate?.isFavorite(fingerprint: fingerprint) ?? false { - NotificationService.shared.sendFavoriteOnlineNotification(nickname: nickname) - } - } - } - } - } else { - // Just update the peer list - self.notifyPeerListUpdate() - } - } - - // If we received this announce from a central (we're peripheral), send our announce back - if let central = fromCentral, let vm = self.delegate as? ChatViewModel { - // Map this central to the peer ID - centralToPeerID[central.identifier] = senderID - - // Only send announce back once per central - if !sentAnnounceBackToCentral.contains(central.identifier) { - sentAnnounceBackToCentral.insert(central.identifier) - - // Send announce back after a small delay to ensure they're ready - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - guard let self = self, - let characteristic = self.characteristic else { return } - - let announcePacket = BitchatPacket( - type: MessageType.announce.rawValue, - ttl: 3, - senderID: self.myPeerID, - payload: Data(vm.nickname.utf8) - ) - - if let data = announcePacket.toBinaryData() { - // Send directly to this central - let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false - if success { - SecureLogger.log("Sent announce back to central \(central.identifier.uuidString.prefix(8)) (peer: \(senderID))", - category: SecureLogger.session, level: .info) - } else { - SecureLogger.log("[ERROR] Failed to send announce back to central - transmit queue full, queueing", - category: SecureLogger.session, level: .error) - // Queue for later sending - self.pendingDataToSend.append((data, characteristic, [central])) - } - } - } - } - } - - // Relay announce if TTL > 0 - if packet.ttl > 1 { - var relayPacket = packet - relayPacket.ttl -= 1 - - // Add small delay to prevent collision - let delay = Double.random(in: 0.1...0.3) - self.scheduleRelay(relayPacket, messageID: messageID, delay: delay) - } - } - - case .leave: - let senderID = packet.senderID.hexEncodedString() - // Legacy peer disconnect (keeping for backwards compatibility) - if String(data: packet.payload, encoding: .utf8) != nil { - // Remove from active peers with proper locking - collectionsQueue.sync(flags: .barrier) { - let wasRemoved = self.peerSessions[senderID]?.isActivePeer == true - let nickname = self.peerSessions.removeValue(forKey: senderID)?.nickname ?? "unknown" - - if wasRemoved { - // Mark as gracefully left to prevent duplicate disconnect message - self.gracefullyLeftPeers.insert(senderID) - self.gracefulLeaveTimestamps[senderID] = Date() - - // Update PeerSession to reflect disconnect - if let session = self.peerSessions[senderID] { - session.isActivePeer = false - session.isConnected = false - session.updateBluetoothConnection(peripheral: nil, characteristic: nil) - session.updateAuthenticationState(authenticated: false, noiseSession: false) - } - - SecureLogger.log("📴 Peer left network: \(senderID) (\(nickname)) - marked as gracefully left", category: SecureLogger.session, level: .info) - } - } - - // Update PeerSession - collectionsQueue.sync(flags: .barrier) { - if let session = self.peerSessions[senderID] { - session.hasReceivedAnnounce = false - session.isActivePeer = false - session.isConnected = false - } - } - - // Show disconnect message immediately when peer leaves - DispatchQueue.main.async { - self.delegate?.didDisconnectFromPeer(senderID) - } - self.notifyPeerListUpdate() - } - - case .fragmentStart, .fragmentContinue, .fragmentEnd: - // let fragmentTypeStr = packet.type == MessageType.fragmentStart.rawValue ? "START" : - // (packet.type == MessageType.fragmentContinue.rawValue ? "CONTINUE" : "END") - - // Validate fragment has minimum required size - if packet.payload.count < 13 { - return - } - - handleFragment(packet, from: peerID) - - // Relay fragments if TTL > 0 - var relayPacket = packet - relayPacket.ttl -= 1 - if relayPacket.ttl > 0 { - let delay = self.exponentialRelayDelay() - self.scheduleRelay(relayPacket, messageID: messageID, delay: delay) - } - - - case .deliveryAck: - // Handle delivery acknowledgment - if let recipientIDData = packet.recipientID, - isPeerIDOurs(recipientIDData.hexEncodedString()) { - // This ACK is for us - let senderID = packet.senderID.hexEncodedString() - // Check if payload is already decrypted (came through Noise) - if let ack = DeliveryAck.fromBinaryData(packet.payload) { - // Already decrypted - process directly - DeliveryTracker.shared.processDeliveryAck(ack) - - - // Notify delegate - DispatchQueue.main.async { - self.delegate?.didReceiveDeliveryAck(ack) - } - } else if let ack = DeliveryAck.decode(from: packet.payload) { - // Fallback to JSON for backward compatibility - DeliveryTracker.shared.processDeliveryAck(ack) - - // Notify delegate - DispatchQueue.main.async { - self.delegate?.didReceiveDeliveryAck(ack) - } - } else { - // Try legacy decryption - do { - let decryptedData = try noiseService.decrypt(packet.payload, from: senderID) - if let ack = DeliveryAck.fromBinaryData(decryptedData) { - // Process the ACK - DeliveryTracker.shared.processDeliveryAck(ack) - - - // Notify delegate - DispatchQueue.main.async { - self.delegate?.didReceiveDeliveryAck(ack) - } - } else if let ack = DeliveryAck.decode(from: decryptedData) { - // Fallback to JSON - DeliveryTracker.shared.processDeliveryAck(ack) - - // Notify delegate - DispatchQueue.main.async { - self.delegate?.didReceiveDeliveryAck(ack) - } - } - } catch { - SecureLogger.log("Failed to decrypt delivery ACK from \(senderID): \(error)", - category: SecureLogger.encryption, level: .error) - } - } - } else if packet.ttl > 0 { - // Relay the ACK if not for us - - // SAFETY CHECK: Never relay unencrypted JSON - if let jsonCheck = String(data: packet.payload.prefix(1), encoding: .utf8), jsonCheck == "{" { - return - } - - var relayPacket = packet - relayPacket.ttl -= 1 - let delay = self.exponentialRelayDelay() - self.scheduleRelay(relayPacket, messageID: messageID, delay: delay) - } - - case .readReceipt: - // Handle read receipt - if let recipientIDData = packet.recipientID, - isPeerIDOurs(recipientIDData.hexEncodedString()) { - // This read receipt is for us - let senderID = packet.senderID.hexEncodedString() - // Received read receipt - // Check if payload is already decrypted (came through Noise) - if let receipt = ReadReceipt.fromBinaryData(packet.payload) { - // Already decrypted - process directly - // Processing read receipt - DispatchQueue.main.async { - self.delegate?.didReceiveReadReceipt(receipt) - } - } else if let receipt = ReadReceipt.decode(from: packet.payload) { - // Fallback to JSON for backward compatibility - // Processing read receipt (JSON) - DispatchQueue.main.async { - self.delegate?.didReceiveReadReceipt(receipt) - } - } else { - // Try legacy decryption - do { - let decryptedData = try noiseService.decrypt(packet.payload, from: senderID) - if let receipt = ReadReceipt.fromBinaryData(decryptedData) { - // Process the read receipt - DispatchQueue.main.async { - self.delegate?.didReceiveReadReceipt(receipt) - } - } else if let receipt = ReadReceipt.decode(from: decryptedData) { - // Fallback to JSON - DispatchQueue.main.async { - self.delegate?.didReceiveReadReceipt(receipt) - } - } - } catch { - // Failed to decrypt read receipt - might be from unknown sender - } - } - } else if packet.ttl > 0 { - // Relay the read receipt if not for us - var relayPacket = packet - relayPacket.ttl -= 1 - let delay = self.exponentialRelayDelay() - self.scheduleRelay(relayPacket, messageID: messageID, delay: delay) - } - - case .noiseIdentityAnnounce: - // Handle Noise identity announcement - let senderID = packet.senderID.hexEncodedString() - - // Check if this identity announce is targeted to someone else - if let recipientID = packet.recipientID, - !isPeerIDOurs(recipientID.hexEncodedString()) { - // Not for us, relay if TTL > 0 - if packet.ttl > 0 { - // Relay identity announce - var relayPacket = packet - relayPacket.ttl -= 1 - let delay = self.exponentialRelayDelay() - self.scheduleRelay(relayPacket, messageID: messageID, delay: delay) - } - return - } - - // Special duplicate detection for identity announces - processedMessagesLock.lock() - if let lastSeenTime = recentIdentityAnnounces[senderID] { - let timeSince = Date().timeIntervalSince(lastSeenTime) - if timeSince < identityAnnounceDuplicateWindow { - processedMessagesLock.unlock() - SecureLogger.log("Dropped duplicate identity announce from \(senderID) (last seen \(timeSince)s ago)", - category: SecureLogger.security, level: .debug) - return - } - } - recentIdentityAnnounces[senderID] = Date() - processedMessagesLock.unlock() - - if senderID != myPeerID && !isPeerIDOurs(senderID) { - // Create defensive copy and validate - let payloadCopy = Data(packet.payload) - - guard !payloadCopy.isEmpty else { - SecureLogger.log("Received empty NoiseIdentityAnnouncement from \(senderID)", category: SecureLogger.noise, level: .error) - return - } - - // Decode the announcement - let announcement: NoiseIdentityAnnouncement? - if let firstByte = payloadCopy.first, firstByte == 0x7B { // '{' character - JSON - announcement = NoiseIdentityAnnouncement.decode(from: payloadCopy) ?? NoiseIdentityAnnouncement.fromBinaryData(payloadCopy) - } else { - announcement = NoiseIdentityAnnouncement.fromBinaryData(payloadCopy) ?? NoiseIdentityAnnouncement.decode(from: payloadCopy) - } - - guard let announcement = announcement else { - SecureLogger.log("Failed to decode NoiseIdentityAnnouncement from \(senderID), size: \(payloadCopy.count)", category: SecureLogger.noise, level: .error) - return - } - - // Verify the signature using the signing public key - let timestampData = String(Int64(announcement.timestamp.timeIntervalSince1970 * 1000)).data(using: .utf8)! - let bindingData = announcement.peerID.data(using: .utf8)! + announcement.publicKey + timestampData - if !noiseService.verifySignature(announcement.signature, for: bindingData, publicKey: announcement.signingPublicKey) { - SecureLogger.log("Signature verification failed for \(senderID)", category: SecureLogger.noise, level: .warning) - return // Reject announcements with invalid signatures - } - - // Calculate fingerprint from public key - let hash = SHA256.hash(data: announcement.publicKey) - let fingerprint = hash.map { String(format: "%02x", $0) }.joined() - - // Log receipt of identity announce - SecureLogger.log("Received identity announce from \(announcement.peerID) (\(announcement.nickname))", - category: SecureLogger.noise, level: .info) - - // Create the binding - let binding = PeerIdentityBinding( - currentPeerID: announcement.peerID, - fingerprint: fingerprint, - publicKey: announcement.publicKey, - signingPublicKey: announcement.signingPublicKey, - nickname: announcement.nickname, - bindingTimestamp: announcement.timestamp, - signature: announcement.signature - ) - - SecureLogger.log("Creating identity binding for \(announcement.peerID) -> \(fingerprint)", category: SecureLogger.security, level: .info) - - // Update our mappings - updatePeerBinding(announcement.peerID, fingerprint: fingerprint, binding: binding) - - // Update connection state only if we're not already authenticated - let currentState = peerConnectionStates[announcement.peerID] ?? .disconnected - if currentState != .authenticated { - // Check if we have a direct connection (either as peripheral or central) - let hasDirectConnection = collectionsQueue.sync { - // Check if any connected peripheral maps to this peer - for (_, mapping) in self.peripheralMappings where mapping.peerID == announcement.peerID { - if self.connectedPeripherals[announcement.peerID] != nil { - return true - } - } - - // Also check if this peer is connected as a central (we're peripheral) - for (_, peerID) in self.centralToPeerID { - if peerID == announcement.peerID { - return true - } - } - - return false - } - - if hasDirectConnection { - updatePeerConnectionState(announcement.peerID, state: .connected) - } else { - // This is a relayed identity announce - don't mark as directly connected - SecureLogger.log("Received relayed identity announce from \(announcement.peerID) - not marking as directly connected", - category: SecureLogger.noise, level: .debug) - } - } - - // Register the peer's public key with ChatViewModel for verification tracking - DispatchQueue.main.async { [weak self] in - (self?.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: announcement.peerID, publicKeyData: announcement.publicKey) - } - - // Lazy handshake: No longer initiate handshake on identity announcement - // Just respond with our own identity announcement - if !noiseService.hasEstablishedSession(with: announcement.peerID) { - // Send our identity back so they know we're here - SecureLogger.log("Responding to identity announce from \(announcement.peerID) with our own (lazy handshake mode)", - category: SecureLogger.noise, level: .info) - sendNoiseIdentityAnnounce(to: announcement.peerID) - } else { - // We already have a session, ensure ChatViewModel knows about the fingerprint - // This handles the case where handshake completed before identity announcement - DispatchQueue.main.async { [weak self] in - if let publicKeyData = self?.noiseService.getPeerPublicKeyData(announcement.peerID) { - (self?.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: announcement.peerID, publicKeyData: publicKeyData) - } - } - } - } - - case .noiseHandshakeInit: - // Handle incoming Noise handshake initiation - let senderID = packet.senderID.hexEncodedString() - SecureLogger.logHandshake("initiation received", peerID: senderID, success: true) - - // Check if this handshake is for us or broadcast - if let recipientID = packet.recipientID, - !isPeerIDOurs(recipientID.hexEncodedString()) { - // Not for us, relay if TTL > 0 - if packet.ttl > 0 { - // Relay handshake init - var relayPacket = packet - relayPacket.ttl -= 1 - broadcastPacket(relayPacket) - } - return - } - if !isPeerIDOurs(senderID) { - // Check if we already have an established session - if noiseService.hasEstablishedSession(with: senderID) { - // Determine who should be initiator based on peer ID comparison - let shouldBeInitiator = myPeerID < senderID - - if shouldBeInitiator { - // We should be initiator but peer is initiating - likely they had a session failure - SecureLogger.log("Received handshake init from \(senderID) who should be responder - likely session mismatch, clearing and accepting", category: SecureLogger.noise, level: .warning) - cleanupPeerCryptoState(senderID) - } else { - // Check if we've heard from this peer recently - let lastHeard = self.peerSessions[senderID]?.lastHeardFromPeer ?? Date.distantPast - let timeSinceLastHeard = Date().timeIntervalSince(lastHeard) - - // Check session validity before clearing - let lastSuccess = self.peerSessions[senderID]?.lastSuccessfulMessageTime ?? Date.distantPast - let sessionAge = Date().timeIntervalSince(lastSuccess) - - // If the peer is initiating a handshake despite us having a valid session, - // they must have cleared their session for a good reason (e.g., decryption failure). - // We should always accept the handshake to re-establish encryption. - SecureLogger.log("Received handshake init from \(senderID) with existing session (age: \(Int(sessionAge))s, last heard: \(Int(timeSinceLastHeard))s ago) - accepting to re-establish encryption", - category: SecureLogger.handshake, level: .info) - cleanupPeerCryptoState(senderID) - } - } - - // If we have a handshaking session, reset it to allow new handshake - if noiseService.hasSession(with: senderID) && !noiseService.hasEstablishedSession(with: senderID) { - SecureLogger.log("Received handshake init from \(senderID) while already handshaking - resetting to allow new handshake", category: SecureLogger.noise, level: .info) - cleanupPeerCryptoState(senderID) - } - - handleNoiseHandshakeMessage(from: senderID, message: packet.payload, isInitiation: true) - - // Send protocol ACK for successfully processed handshake initiation - sendProtocolAck(for: packet, to: senderID) - } - - case .noiseHandshakeResp: - // Handle Noise handshake response - let senderID = packet.senderID.hexEncodedString() - SecureLogger.logHandshake("response received", peerID: senderID, success: true) - - // Check if this handshake response is for us - if let recipientID = packet.recipientID { - let recipientIDStr = recipientID.hexEncodedString() - // Response targeted check - if !isPeerIDOurs(recipientIDStr) { - // Not for us, relay if TTL > 0 - if packet.ttl > 0 { - // Relay handshake response - var relayPacket = packet - relayPacket.ttl -= 1 - broadcastPacket(relayPacket) - } - return - } - } - - if !isPeerIDOurs(senderID) { - // Check our current handshake state - // Processing handshake response - - // Process the response - this could be message 2 or message 3 in the XX pattern - handleNoiseHandshakeMessage(from: senderID, message: packet.payload, isInitiation: false) - - // Send protocol ACK for successfully processed handshake response - sendProtocolAck(for: packet, to: senderID) - } - - case .noiseEncrypted: - // Handle Noise encrypted message - let senderID = packet.senderID.hexEncodedString() - if !isPeerIDOurs(senderID) { - let recipientID = packet.recipientID?.hexEncodedString() ?? "" - - // Check if this message is for us - if isPeerIDOurs(recipientID) { - // Message is for us, try to decrypt - handleNoiseEncryptedMessage(from: senderID, encryptedData: packet.payload, originalPacket: packet, peripheral: peripheral) - } else if packet.ttl > 1 { - // Message is not for us but has TTL > 1, consider relaying - // Only relay if we think we might be able to reach the recipient - - // Check if recipient is directly connected to us - let canReachDirectly = connectedPeripherals[recipientID] != nil - - // Check if we've seen this recipient recently (might be reachable via relay) - let seenRecently = collectionsQueue.sync { - if let lastSeen = self.peerLastSeenTimestamps.get(recipientID) { - return Date().timeIntervalSince(lastSeen) < 180.0 // Seen in last 3 minutes - } - return false - } - - if canReachDirectly || seenRecently { - // Relay the message with reduced TTL - var relayPacket = packet - relayPacket.ttl = min(packet.ttl - 1, 2) // Decrement TTL, max 2 for relayed private messages - - SecureLogger.log("Relaying private message from \(senderID) to \(recipientID) (TTL: \(relayPacket.ttl))", - category: SecureLogger.session, level: .debug) - - if canReachDirectly { - // Send directly to recipient - _ = sendDirectToRecipient(relayPacket, recipientPeerID: recipientID) - } else { - // Use selective relay - sendViaSelectiveRelay(relayPacket, recipientPeerID: recipientID) - } - } else { - SecureLogger.log("Not relaying private message to \(recipientID) - recipient not reachable", - category: SecureLogger.session, level: .debug) - } - } else { - // recipientID is empty or invalid, try to decrypt anyway (backwards compatibility) - handleNoiseEncryptedMessage(from: senderID, encryptedData: packet.payload, originalPacket: packet, peripheral: peripheral) - } - } - - case .protocolAck: - // Handle protocol-level acknowledgment - let senderID = packet.senderID.hexEncodedString() - if !isPeerIDOurs(senderID) { - handleProtocolAck(from: senderID, data: packet.payload) - } - - case .protocolNack: - // Handle protocol-level negative acknowledgment - let senderID = packet.senderID.hexEncodedString() - if let recipientIDData = packet.recipientID, - isPeerIDOurs(recipientIDData.hexEncodedString()) - && !isPeerIDOurs(senderID) { - handleProtocolNack(from: senderID, data: packet.payload) - } - - case .systemValidation: - // Handle system validation ping (for session sync verification) - let senderID = packet.senderID.hexEncodedString() - if !isPeerIDOurs(senderID) { - // Try to decrypt the validation ping - do { - let decrypted = try noiseService.decrypt(packet.payload, from: senderID) - SecureLogger.log("Successfully validated session with \(senderID) - ping: \(String(data: decrypted, encoding: .utf8) ?? "?")", - category: SecureLogger.session, level: .debug) - - // Session is valid, update last successful message time - updateLastSuccessfulMessageTime(senderID) - - // Note: PeerSession already updated in helper - if let session = self.peerSessions[senderID] { - session.lastSuccessfulMessageTime = Date() - } - } catch { - // Validation failed - session is out of sync - SecureLogger.log("Session validation failed with \(senderID): \(error)", - category: SecureLogger.session, level: .warning) - - // Send NACK to trigger session re-establishment - sendProtocolNack(for: packet, to: senderID, - reason: "Session validation failed", - errorCode: .decryptionFailed) - } - } - - case .handshakeRequest: - // Handle handshake request for pending messages - let senderID = packet.senderID.hexEncodedString() - if !isPeerIDOurs(senderID) { - handleHandshakeRequest(from: senderID, data: packet.payload) - } - - case .favorited: - // Now handled as private messages with "SYSTEM:FAVORITED" content - // See handleReceivedPacket for MESSAGE type handling - break - - case .unfavorited: - // Now handled as private messages with "SYSTEM:UNFAVORITED" content - // See handleReceivedPacket for MESSAGE type handling - break - - default: - break - } - } - } - - private func sendFragmentedPacket(_ packet: BitchatPacket) { - guard let fullData = packet.toBinaryData() else { return } - - // Generate a fixed 8-byte fragment ID - var fragmentID = Data(count: 8) - fragmentID.withUnsafeMutableBytes { bytes in - arc4random_buf(bytes.baseAddress, 8) - } - - let fragments = stride(from: 0, to: fullData.count, by: maxFragmentSize).map { offset in - fullData[offset..> 8) & 0xFF)) - fragmentPayload.append(UInt8(index & 0xFF)) - fragmentPayload.append(UInt8((fragments.count >> 8) & 0xFF)) - fragmentPayload.append(UInt8(fragments.count & 0xFF)) - fragmentPayload.append(packet.type) - fragmentPayload.append(fragmentData) - - let fragmentType: MessageType - if index == 0 { - fragmentType = .fragmentStart - } else if index == fragments.count - 1 { - fragmentType = .fragmentEnd - } else { - fragmentType = .fragmentContinue - } - - let fragmentPacket = BitchatPacket( - type: fragmentType.rawValue, - senderID: packet.senderID, // Use original packet's senderID (already Data) - recipientID: packet.recipientID, // Preserve recipient if any - timestamp: packet.timestamp, // Use original timestamp - payload: fragmentPayload, - signature: nil, // Fragments don't need signatures - ttl: packet.ttl ) - - // Send fragments with linear delay - let totalDelay = Double(index) * delayBetweenFragments - - // Send fragments on background queue with calculated delay - messageQueue.asyncAfter(deadline: .now() + totalDelay) { [weak self] in - self?.broadcastPacket(fragmentPacket) - } - } - - } - - private func handleFragment(_ packet: BitchatPacket, from peerID: String) { - // Handling fragment - - guard packet.payload.count >= 13 else { - return - } - - // Convert to array for safer access - let payloadArray = Array(packet.payload) - var offset = 0 - - // Extract fragment ID as binary data (8 bytes) - guard payloadArray.count >= 8 else { - return - } - - let fragmentIDData = Data(payloadArray[0..<8]) - let fragmentID = fragmentIDData.hexEncodedString() - offset = 8 - - // Safely extract index - guard payloadArray.count >= offset + 2 else { - // Not enough data for index - return - } - let index = Int(payloadArray[offset]) << 8 | Int(payloadArray[offset + 1]) - offset += 2 - - // Safely extract total - guard payloadArray.count >= offset + 2 else { - // Not enough data for total - return - } - let total = Int(payloadArray[offset]) << 8 | Int(payloadArray[offset + 1]) - offset += 2 - - // Safely extract original type - guard payloadArray.count >= offset + 1 else { - // Not enough data for type - return - } - let originalType = payloadArray[offset] - offset += 1 - - // Extract fragment data - let fragmentData: Data - if payloadArray.count > offset { - fragmentData = Data(payloadArray[offset...]) - } else { - fragmentData = Data() - } - - - // Initialize fragment collection if needed - if incomingFragments[fragmentID] == nil { - // Check if we've reached the concurrent session limit - if incomingFragments.count >= maxConcurrentFragmentSessions { - // Clean up oldest fragments first - cleanupOldFragments() - - // If still at limit, reject new session to prevent DoS - if incomingFragments.count >= maxConcurrentFragmentSessions { - return - } - } - - incomingFragments[fragmentID] = [:] - fragmentMetadata[fragmentID] = (originalType, total, Date()) - } - - incomingFragments[fragmentID]?[index] = fragmentData - - - // Check if we have all fragments - if let fragments = incomingFragments[fragmentID], - fragments.count == total { - - // Reassemble the original packet - var reassembledData = Data() - for i in 0.. maxFragmentBytes { - // Remove oldest fragments until under limit - let sortedFragments = fragmentMetadata.sorted { $0.value.timestamp < $1.value.timestamp } - for (fragID, _) in sortedFragments { - incomingFragments.removeValue(forKey: fragID) - fragmentMetadata.removeValue(forKey: fragID) - - // Recalculate total - totalFragmentBytes = 0 - for (_, fragments) in incomingFragments { - for (_, data) in fragments { - totalFragmentBytes += data.count - } - } - - if totalFragmentBytes <= maxFragmentBytes { - break - } - } - } - } -} // End of BluetoothMeshService class - -extension BluetoothMeshService: CBCentralManagerDelegate { - // MARK: - CBCentralManagerDelegate - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - // Central manager state updated - let stateString: String - switch central.state { - case .unknown: stateString = "unknown" - case .resetting: stateString = "resetting" - case .unsupported: stateString = "unsupported" - case .unauthorized: stateString = "unauthorized" - case .poweredOff: stateString = "poweredOff" - case .poweredOn: stateString = "poweredOn" - @unknown default: stateString = "unknown default" - } - - SecureLogger.log("[CENTRAL] Central manager state changed to: \(stateString)", - category: SecureLogger.session, level: .info) - - // Notify ChatViewModel of Bluetooth state change - if let chatViewModel = delegate as? ChatViewModel { - Task { @MainActor in - chatViewModel.updateBluetoothState(central.state) - } - } - - if central.state == .unsupported { - } else if central.state == .unauthorized { - } else if central.state == .poweredOff { - } else if central.state == .poweredOn { - startScanning() - - // Send announces when central manager is ready - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.sendBroadcastAnnounce() - } - } - } - - func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { - // Restore central manager state after app backgrounding - SecureLogger.log("[RESTORE] Restoring CBCentralManager state", level: .info) - - // Restore peripherals - if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] { - SecureLogger.log("[RESTORE] Restoring \(peripherals.count) peripherals", level: .info) - - collectionsQueue.async(flags: .barrier) { [weak self] in - guard let self = self else { return } - - // Log details about restored peripherals - for peripheral in peripherals { - SecureLogger.log("[RESTORE] Restored peripheral \(peripheral.identifier.uuidString.prefix(8)), state: \(peripheral.state.rawValue), name: \(peripheral.name ?? "nil")", - category: SecureLogger.session, level: .debug) - } - - // Only restore connected peripherals, not the full discovered list - // This ensures we attempt fresh connections on app launch - for peripheral in peripherals { - if peripheral.state == .connected { - // Add connected peripherals to discovered list - self.discoveredPeripherals.append(peripheral) - // Find the peerID for this peripheral - let peerID = peripheral.identifier.uuidString - - // Update our tracking - self.updatePeripheralConnection(peerID, peripheral: peripheral) - - // Set delegate and discover services - peripheral.delegate = self - peripheral.discoverServices([BluetoothMeshService.serviceUUID]) - - } - } - } - } - } - - func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { - let peripheralID = peripheral.identifier.uuidString - - // Extract peer ID from name or advertisement data (macOS compatibility) - // Peer IDs are 8 bytes = 16 hex characters - var discoveredPeerID: String? = nil - - // First try peripheral name - if let name = peripheral.name, name.count == 16 { - discoveredPeerID = name - } - - // macOS fix: Also check advertisement data local name - if discoveredPeerID == nil, - let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String, - localName.count == 16 { - discoveredPeerID = localName - } - - if let peerID = discoveredPeerID { - // Found peer ID - - // Don't process our own advertisements (including previous peer IDs) - if isPeerIDOurs(peerID) { - return - } - - // Discovered potential peer - - // Check if we have a relay-only session for this peer that needs upgrading - collectionsQueue.sync { - if let session = self.peerSessions[peerID], - !session.isConnected && session.hasReceivedAnnounce { - SecureLogger.log("Found relay-only session for \(peerID), will upgrade to direct connection", - category: SecureLogger.session, level: .info) - } - } - } else { - } - - // Connection pooling with exponential backoff - // peripheralID already declared above - - // Check if we should attempt connection (considering backoff) - if let backoffTime = connectionBackoff[peripheralID], - Date().timeIntervalSince1970 < backoffTime { - // Still in backoff period, skip connection - return - } - - // Check if we already have this peripheral in our pool - if let pooledPeripheral = connectionPool[peripheralID] { - // Reuse existing peripheral from pool - if pooledPeripheral.state == CBPeripheralState.disconnected { - // Reconnect if disconnected with optimized parameters - let connectionOptions: [String: Any] = [ - CBConnectPeripheralOptionNotifyOnConnectionKey: true, - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionNotifyOnNotificationKey: true - ] - - // Smart compromise: would set low latency for small networks if API supported it - // iOS/macOS don't expose connection interval control in public API - - central.connect(pooledPeripheral, options: connectionOptions) - } - return - } - - // New peripheral - add to pool and connect - if !discoveredPeripherals.contains(peripheral) { - SecureLogger.log("[NEW] Found peripheral: \(peripheral.name ?? "unnamed") (\(peripheralID.prefix(8))), RSSI: \(RSSI)", - category: SecureLogger.session, level: .info) - - // Check connection pool limits - let connectedCount = connectionPool.values.filter { $0.state == .connected }.count - if connectedCount >= maxConnectedPeripherals { - // Connection pool is full - find least recently used peripheral to disconnect - if let lruPeripheralID = findLeastRecentlyUsedPeripheral() { - if let lruPeripheral = connectionPool[lruPeripheralID] { - SecureLogger.log("Connection pool full, disconnecting LRU peripheral: \(lruPeripheralID)", - category: SecureLogger.session, level: .debug) - central.cancelPeripheralConnection(lruPeripheral) - connectionPool.removeValue(forKey: lruPeripheralID) - } - } - } - - discoveredPeripherals.append(peripheral) - peripheral.delegate = self - connectionPool[peripheralID] = peripheral - - // Track connection attempts - let attempts = connectionAttempts[peripheralID] ?? 0 - connectionAttempts[peripheralID] = attempts + 1 - - // Only attempt if under max attempts - if attempts < maxConnectionAttempts { - // Use optimized connection parameters based on peer count - let connectionOptions: [String: Any] = [ - CBConnectPeripheralOptionNotifyOnConnectionKey: true, - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionNotifyOnNotificationKey: true - ] - - // Smart compromise: would set low latency for small networks if API supported it - // iOS/macOS don't expose connection interval control in public API - - SecureLogger.log("[CONNECT] Connecting to \(peripheral.name ?? String(peripheralID.prefix(8)))...", - category: SecureLogger.session, level: .info) - central.connect(peripheral, options: connectionOptions) - } else { - SecureLogger.log("[CONNECT] Max connection attempts reached for \(peripheralID.prefix(8)) (attempts: \(attempts))", - category: SecureLogger.session, level: .warning) - } - } - } - - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - let peripheralID = peripheral.identifier.uuidString - SecureLogger.log("[CONNECTED] Successfully connected to peripheral \(peripheralID.prefix(8))", - category: SecureLogger.session, level: .info) - - // Log current peripheral mappings - - peripheral.delegate = self - peripheral.discoverServices([BluetoothMeshService.serviceUUID]) - - // Register peripheral in simplified mapping system - registerPeripheral(peripheral) - - // Store peripheral temporarily until we get the real peer ID - updatePeripheralConnection(peripheralID, peripheral: peripheral) - - - // Update connection state to connected (but not authenticated yet) - // We don't know the real peer ID yet, so we can't update the state - - // Don't show connected message yet - wait for key exchange - // This prevents the connect/disconnect/connect pattern - - - // iOS 11+ BLE 5.0: Request 2M PHY for better range and speed - if #available(iOS 11.0, macOS 10.14, *) { - // 2M PHY provides better range than 1M PHY - // This is a hint - system will use best available - } - } - - func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - let peripheralID = peripheral.identifier.uuidString - - - // Check if this was an intentional disconnect - if intentionalDisconnects.contains(peripheralID) { - intentionalDisconnects.remove(peripheralID) - // Intentional disconnect - // Don't process this disconnect further - return - } - - // Clean up subscription tracking for this peripheral - subscribedCharacteristics = subscribedCharacteristics.filter { !$0.hasPrefix("\(peripheralID):") } - sentAnnounceToPeripheral.remove(peripheralID) - - // Log disconnect with error if present - if let error = error { - SecureLogger.logError(error, context: "Peripheral disconnected: \(peripheralID)", category: SecureLogger.session) - } else { - // Peripheral disconnected normally - } - - // Find the real peer ID using simplified mapping - var realPeerID: String? = nil - - if let mapping = peripheralMappings[peripheralID], let peerID = mapping.peerID { - realPeerID = peerID - SecureLogger.log("Found peer ID \(peerID) for peripheral \(peripheralID)", - category: SecureLogger.session, level: .debug) - } else if let peerID = peerIDByPeripheralID[peripheralID] { - // Fallback to legacy mapping - realPeerID = peerID - SecureLogger.log("Found peer ID \(peerID) from legacy mapping for peripheral \(peripheralID)", - category: SecureLogger.session, level: .debug) - } else { - SecureLogger.log("No peer ID mapping found for peripheral \(peripheralID)", - category: SecureLogger.session, level: .debug) - - // Fallback: check if we have a direct mapping from peripheral to peer ID - for (peerID, connectedPeripheral) in connectedPeripherals { - if connectedPeripheral.identifier == peripheral.identifier { - // Check if this is a real peer ID (16 hex chars) not a temp ID - if peerID.count == 16 && peerID.allSatisfy({ $0.isHexDigit }) { - realPeerID = peerID - SecureLogger.log("Found peer ID \(peerID) from connectedPeripherals fallback", - category: SecureLogger.session, level: .debug) - break - } else { - SecureLogger.log("Skipping non-peer ID '\(peerID)' (length: \(peerID.count))", - category: SecureLogger.session, level: .debug) - } - } - } - } - - // Update connection state immediately if we have a real peer ID - if let peerID = realPeerID { - // Update peer connection state - updatePeerConnectionState(peerID, state: .disconnected) - - // Update PeerSession to reflect disconnect - collectionsQueue.async(flags: .barrier) { [weak self] in - if let session = self?.peerSessions[peerID] { - session.updateBluetoothConnection(peripheral: nil, characteristic: nil) - session.isConnected = false - session.updateAuthenticationState(authenticated: false, noiseSession: false) - } - } - - // Clear pending messages for disconnected peer to prevent retry loops - collectionsQueue.async(flags: .barrier) { [weak self] in - if let pendingCount = self?.pendingPrivateMessages[peerID]?.count, pendingCount > 0 { - SecureLogger.log("Clearing \(pendingCount) pending messages for disconnected peer \(peerID)", - category: SecureLogger.session, level: .info) - self?.pendingPrivateMessages[peerID]?.removeAll() - } - } - - // Clean up crypto state and handshake state on disconnect - cleanupPeerCryptoState(peerID) - - // Check if peer gracefully left and notify delegate - let shouldNotifyDisconnect = collectionsQueue.sync { - let isGracefullyLeft = self.gracefullyLeftPeers.contains(peerID) - - // Check if we recently sent a disconnect notification for this peer - let now = Date() - if let lastNotification = self.recentDisconnectNotifications[peerID] { - let timeSinceLastNotification = now.timeIntervalSince(lastNotification) - if timeSinceLastNotification < self.disconnectNotificationDedupeWindow { - // Duplicate disconnect, not notifying - return false - } - } - - // Check if this is an Unknown peer that never properly connected - if let session = self.peerSessions[peerID] { - if session.nickname == "Unknown" && !session.hasReceivedAnnounce { - // Unknown peer disconnect, not notifying - return false - } - } - - if isGracefullyLeft { - // Graceful disconnect, not notifying - return false - } else { - // Ungraceful disconnect, will notify - // Track this notification - self.recentDisconnectNotifications[peerID] = now - return true - } - } - - if shouldNotifyDisconnect { - DispatchQueue.main.async { - self.delegate?.didDisconnectFromPeer(peerID) - } - } - } - - // Implement exponential backoff for failed connections - if error != nil { - let attempts = connectionAttempts[peripheralID] ?? 0 - if attempts >= maxConnectionAttempts { - // Max attempts reached, apply long backoff - let backoffDuration = baseBackoffInterval * pow(2.0, Double(attempts)) - connectionBackoff[peripheralID] = Date().timeIntervalSince1970 + backoffDuration - } - } else { - // Clean disconnect, reset attempts - connectionAttempts[peripheralID] = 0 - connectionBackoff.removeValue(forKey: peripheralID) - } - - // Clean up peripheral tracking - peripheralMappings.removeValue(forKey: peripheralID) - peerIDByPeripheralID.removeValue(forKey: peripheralID) - lastActivityByPeripheralID.removeValue(forKey: peripheralID) - - // Find peer ID for this peripheral (could be temp ID or real ID) - var foundPeerID: String? = nil - for (id, per) in connectedPeripherals { - if per == peripheral { - foundPeerID = id - break - } - } - - if let peerID = foundPeerID { - connectedPeripherals.removeValue(forKey: peerID) - peripheralCharacteristics.removeValue(forKey: peripheral) - - // Don't clear Noise session on disconnect - sessions should survive disconnects - // The Noise protocol is designed to maintain sessions across network interruptions - // Only clear sessions on authentication failure - if peerID.count == 16 { // Real peer ID - // Clear connection time and last heard tracking on disconnect to properly detect stale sessions - // Time tracking removed - now in PeerSession - // Time tracking removed - now in PeerSession - // Keep lastSuccessfulMessageTime to validate session on reconnect - // Keeping Noise session on disconnect - } - - // Only remove from active peers if it's not a temp ID - // Temp IDs shouldn't be marked as active anyway - let (removed, _) = collectionsQueue.sync(flags: .barrier) { - var removed = false - if peerID.count == 16 { // Real peer ID (8 bytes = 16 hex chars) - removed = self.peerSessions[peerID]?.isActivePeer == true - if removed, let session = self.peerSessions[peerID] { - session.isActivePeer = false - } - if removed { - // Only log disconnect if peer didn't gracefully leave - if !self.gracefullyLeftPeers.contains(peerID) { - let nickname = self.peerSessions[peerID]?.nickname ?? "unknown" - SecureLogger.log("📴 Peer disconnected from network: \(peerID) (\(nickname))", category: SecureLogger.session, level: .info) - } else { - // Peer gracefully left, just clean up the tracking - self.gracefullyLeftPeers.remove(peerID) - self.gracefulLeaveTimestamps.removeValue(forKey: peerID) - // Cleaning up gracefullyLeftPeers - } - } - - // Update PeerSession - if let session = self.peerSessions[peerID] { - session.hasReceivedAnnounce = false - session.hasAnnounced = false - } - // hasAnnounced already updated in PeerSession above - } else { - } - - - // Peer disconnected - - return (removed, self.peerSessions[peerID]?.nickname) - } - - // Always notify peer list update on disconnect, regardless of whether peer was active - // This ensures UI stays in sync even if there was a state mismatch - self.notifyPeerListUpdate(immediate: true) - - if removed { - // Mark when network became empty, but don't reset flag immediately - let currentNetworkSize = collectionsQueue.sync { - peerSessions.values.filter { $0.isActivePeer }.count - } - if currentNetworkSize == 0 && networkBecameEmptyTime == nil { - networkBecameEmptyTime = Date() - } - } - } - - // Keep in pool but remove from discovered list - discoveredPeripherals.removeAll { $0 == peripheral } - - // Continue scanning for reconnection - if centralManager?.state == .poweredOn { - // Stop and restart to ensure clean state - centralManager?.stopScan() - centralManager?.scanForPeripherals(withServices: [BluetoothMeshService.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) - } - } -} - -extension BluetoothMeshService: CBPeripheralDelegate { - // MARK: - CBPeripheralDelegate - - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - if let error = error { - SecureLogger.log("Error discovering services: \(error)", - category: SecureLogger.encryption, level: .error) - return - } - - guard let services = peripheral.services else { return } - - SecureLogger.log("Discovered \(services.count) services on peripheral \(peripheral.identifier.uuidString.prefix(8))", - category: SecureLogger.session, level: .debug) - - for service in services { - SecureLogger.log("Discovering characteristics for service \(service.uuid.uuidString.prefix(8)) on peripheral \(peripheral.identifier.uuidString.prefix(8))", - category: SecureLogger.session, level: .debug) - peripheral.discoverCharacteristics([BluetoothMeshService.characteristicUUID], for: service) - } - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - if let error = error { - SecureLogger.log("Error discovering characteristics: \(error)", - category: SecureLogger.encryption, level: .error) - return - } - - guard let characteristics = service.characteristics else { return } - - let peripheralID = peripheral.identifier.uuidString - - SecureLogger.log("Found \(characteristics.count) characteristics in service \(service.uuid.uuidString.prefix(8)) on peripheral \(peripheralID.prefix(8))", - category: SecureLogger.session, level: .debug) - - for characteristic in characteristics { - SecureLogger.log("Checking characteristic \(characteristic.uuid.uuidString.prefix(8)) vs target \(BluetoothMeshService.characteristicUUID.uuidString.prefix(8))", - category: SecureLogger.session, level: .debug) - if characteristic.uuid == BluetoothMeshService.characteristicUUID { - // Create unique key for this peripheral+characteristic combo - let charKey = "\(peripheralID):\(characteristic.uuid.uuidString)" - - // Subscribe to this characteristic if we haven't already - if !subscribedCharacteristics.contains(charKey) { - subscribedCharacteristics.insert(charKey) - peripheral.setNotifyValue(true, for: characteristic) - - // Store the first characteristic we find for writing - if peripheralCharacteristics[peripheral] == nil { - peripheralCharacteristics[peripheral] = characteristic - - // Request maximum MTU for faster data transfer (only once per peripheral) - peripheral.maximumWriteValueLength(for: .withoutResponse) - } - - SecureLogger.log("Subscribed to characteristic on peripheral \(peripheral.identifier.uuidString.prefix(8))", - category: SecureLogger.session, level: .debug) - } - - // Send announce packet only once per peripheral - if !sentAnnounceToPeripheral.contains(peripheralID) { - sentAnnounceToPeripheral.insert(peripheralID) - - if let vm = self.delegate as? ChatViewModel { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self, weak peripheral] in - guard let self = self, - let peripheral = peripheral, - peripheral.state == .connected, - let writeChar = self.peripheralCharacteristics[peripheral] else { return } - - let announcePacket = BitchatPacket( - type: MessageType.announce.rawValue, - ttl: 3, - senderID: self.myPeerID, - payload: Data(vm.nickname.utf8) - ) - - // Send directly to this peripheral - if let data = announcePacket.toBinaryData() { - self.writeToPeripheral(data, peripheral: peripheral, characteristic: writeChar, peerID: nil) - SecureLogger.log("Sent announce directly to peripheral \(peripheral.identifier.uuidString.prefix(8))", - category: SecureLogger.session, level: .info) - } - - // Then broadcast to others - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.broadcastPacket(announcePacket) - } - } - } else if peripheral.state == .disconnected { - // Attempt to reconnect to disconnected peripherals - SecureLogger.log("[RESTORE] Attempting to reconnect to disconnected peripheral \(peripheral.identifier.uuidString.prefix(8))", - category: SecureLogger.session, level: .info) - - peripheral.delegate = self - self.connectionPool[peripheral.identifier.uuidString] = peripheral - - // Attempt reconnection after central manager is ready - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - guard let self = self, self.centralManager?.state == .poweredOn else { return } - - let connectionOptions: [String: Any] = [ - CBConnectPeripheralOptionNotifyOnConnectionKey: true, - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionNotifyOnNotificationKey: true - ] - - self.centralManager?.connect(peripheral, options: connectionOptions) - SecureLogger.log("[RESTORE] Initiated reconnection to peripheral \(peripheral.identifier.uuidString.prefix(8))", - category: SecureLogger.session, level: .info) - } - } - } - } - } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - if let error = error { - SecureLogger.log("[ERROR] Error receiving data from peripheral: \(error.localizedDescription)", - category: SecureLogger.session, level: .error) - return - } - - guard let data = characteristic.value else { - SecureLogger.log("[WARNING] Received notification with no data from peripheral \(peripheral.identifier.uuidString.prefix(8)) on char \(characteristic.uuid.uuidString.prefix(8))", - category: SecureLogger.session, level: .warning) - return - } - - SecureLogger.log("Received \(data.count) bytes from peripheral \(peripheral.identifier.uuidString.prefix(8)) on char \(characteristic.uuid.uuidString.prefix(8))", - category: SecureLogger.session, level: .info) - - // Update activity tracking for this peripheral - updatePeripheralActivity(peripheral.identifier.uuidString) - - - guard let packet = BitchatPacket.from(data) else { - SecureLogger.log("[ERROR] Failed to parse packet from peripheral \(peripheral.identifier.uuidString.prefix(8))", - category: SecureLogger.session, level: .error) - return - } - - - // Use the sender ID from the packet, not our local mapping which might still be a temp ID - let packetSenderID = packet.senderID.hexEncodedString() - - // Log the packet type we received - if let messageType = MessageType(rawValue: packet.type) { - SecureLogger.log("Received \(messageType.description) from \(packetSenderID) via peripheral \(peripheral.identifier.uuidString.prefix(8))", - category: SecureLogger.session, level: .info) - } - - // Always handle received packets - handleReceivedPacket(packet, from: packetSenderID, peripheral: peripheral) - } - - func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - if let error = error { - // Log error but don't spam for common errors - let errorCode = (error as NSError).code - if errorCode != 242 { // Don't log the common "Unknown ATT error" - } - } else { - } - } - - func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { - peripheral.discoverServices([BluetoothMeshService.serviceUUID]) - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { - if let error = error { - SecureLogger.log("[ERROR] Failed to update notification state for peripheral \(peripheral.identifier.uuidString.prefix(8)): \(error)", - category: SecureLogger.session, level: .error) - } else if characteristic.isNotifying { - SecureLogger.log("Successfully subscribed to notifications from peripheral \(peripheral.identifier.uuidString.prefix(8))", - category: SecureLogger.session, level: .info) - } else { - SecureLogger.log("Unsubscribed from notifications for peripheral \(peripheral.identifier.uuidString.prefix(8))", - category: SecureLogger.session, level: .info) - } - } -} - -extension BluetoothMeshService: CBPeripheralManagerDelegate { - // MARK: - CBPeripheralManagerDelegate - - func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { - SecureLogger.log("Peripheral manager ready to send - processing \(pendingDataToSend.count) pending sends", - category: SecureLogger.session, level: .debug) - - // Try to send pending data - while !pendingDataToSend.isEmpty { - let (data, characteristic, centrals) = pendingDataToSend.removeFirst() - let success = peripheral.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) - if !success { - // Queue is full again, re-add to front and stop - pendingDataToSend.insert((data, characteristic, centrals), at: 0) - break - } else { - SecureLogger.log("Successfully sent pending data (\(data.count) bytes)", - category: SecureLogger.session, level: .debug) - } - } - } - - func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { - // Peripheral manager state updated - SecureLogger.log("[PERIPHERAL] Peripheral manager state changed to: \(peripheral.state.rawValue)", level: .info) - - switch peripheral.state { - case .unknown: SecureLogger.log("[PERIPHERAL] State: unknown", level: .warning) - case .resetting: SecureLogger.log("[PERIPHERAL] State: resetting", level: .warning) - case .unsupported: SecureLogger.log("[PERIPHERAL] State: unsupported", level: .error) - case .unauthorized: SecureLogger.log("[PERIPHERAL] State: unauthorized", level: .error) - case .poweredOff: SecureLogger.log("[PERIPHERAL] State: powered off", level: .warning) - case .poweredOn: SecureLogger.log("[PERIPHERAL] State: powered on - ready to advertise", level: .info) - @unknown default: SecureLogger.log("[PERIPHERAL] State: unknown state \(peripheral.state.rawValue)", level: .warning) - } - - // Notify ChatViewModel of Bluetooth state change - if let chatViewModel = delegate as? ChatViewModel { - Task { @MainActor in - chatViewModel.updateBluetoothState(peripheral.state) - } - } - - switch peripheral.state { - case .unsupported: - break - case .unauthorized: - break - case .poweredOff: - break - case .poweredOn: - setupPeripheral() - startAdvertising() - - // Send announces when peripheral manager is ready - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.sendBroadcastAnnounce() - } - default: - break - } - } - - func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) { - // Restore peripheral manager state after app backgrounding - SecureLogger.log("[RESTORE] Restoring CBPeripheralManager state", level: .info) - - // Restore services - if let services = dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService] { - SecureLogger.log("[RESTORE] Restoring \(services.count) services", level: .info) - - // Services are automatically restored by Core Bluetooth - // We just need to ensure our internal state is consistent - DispatchQueue.main.async { [weak self] in - self?.isAdvertising = false - // Will be set to true when advertising starts - } - } - - // Restore advertisement data - if let advertisementData = dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any] { - SecureLogger.log("[RESTORE] Restoring advertisement data", level: .info) - self.advertisementData = advertisementData - - DispatchQueue.main.async { [weak self] in - self?.isAdvertising = true - } - } - } - - func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { - // Service added - } - - func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) { - // Advertising state changed - } - - func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { - - for request in requests { - if let data = request.value { - - if let packet = BitchatPacket.from(data) { - let peerID = packet.senderID.hexEncodedString() - - // Log specific Noise packet types - switch packet.type { - case MessageType.noiseHandshakeInit.rawValue: - break - case MessageType.noiseHandshakeResp.rawValue: - break - case MessageType.noiseEncrypted.rawValue: - break - default: - break - } - - // Try to identify peer from packet - // peerID already declared above - - // Store the central for updates - if !subscribedCentrals.contains(request.central) { - subscribedCentrals.append(request.central) - } - - // Track this peer as connected - if peerID != "unknown" && peerID != myPeerID { - // Double-check we're not adding ourselves - if peerID == self.myPeerID { - SecureLogger.log("Preventing self from being added as peer (peripheral manager)", category: SecureLogger.noise, level: .warning) - peripheral.respond(to: request, withResult: .success) - return - } - - // Store the mapping from central to peer ID - centralToPeerID[request.central.identifier] = peerID - - // Note: Legacy keyExchange (0x02) no longer handled - - self.notifyPeerListUpdate() - } - - // Pass the central as a "peripheral" to indicate direct connection - // When we're the peripheral, we don't have a CBPeripheral object, - // but we need to indicate this is a direct connection - handleReceivedPacket(packet, from: peerID, peripheral: nil, decrypted: false, fromCentral: request.central) - peripheral.respond(to: request, withResult: .success) - } else { - peripheral.respond(to: request, withResult: .invalidPdu) - } - } else { - peripheral.respond(to: request, withResult: .invalidPdu) - } - } - } - - func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { - if !subscribedCentrals.contains(central) { - subscribedCentrals.append(central) - SecureLogger.log("[SUBSCRIBE] Central subscribed: \(central.identifier.uuidString.prefix(8)), total centrals: \(subscribedCentrals.count)", - category: SecureLogger.session, level: .info) - - // Send announce packet to the newly connected central - // Add a slightly longer delay to ensure the central is ready to receive - if let vm = self.delegate as? ChatViewModel { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self = self else { return } - - let announcePacket = BitchatPacket( - type: MessageType.announce.rawValue, - ttl: 3, - senderID: self.myPeerID, - payload: Data(vm.nickname.utf8) - ) - - // Send to all subscribed centrals (including the new one) - if let data = announcePacket.toBinaryData(), - let characteristic = self.characteristic { - // Send to all subscribed centrals instead of just the new one - // This ensures the data is properly delivered - let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false - if success { - SecureLogger.log("Successfully sent announce to \(self.subscribedCentrals.count) subscribed central(s) (triggered by \(central.identifier.uuidString.prefix(8)))", - category: SecureLogger.session, level: .info) - } else { - // If updateValue returns false, the transmit queue is full - // We should queue this data to send when ready - SecureLogger.log("[ERROR] Failed to send announce - transmit queue full, queueing (char valid: \(characteristic.uuid.uuidString.prefix(8)), centrals: \(self.subscribedCentrals.count))", - category: SecureLogger.session, level: .error) - // Queue for later sending - self.pendingDataToSend.append((data, characteristic, nil)) - } - } else { - SecureLogger.log("[ERROR] Could not send announce - data: \(announcePacket.toBinaryData() != nil), char: \(self.characteristic != nil)", - category: SecureLogger.session, level: .error) - } - } - } - - // Update peer list to show we're connected (even without peer ID yet) - self.notifyPeerListUpdate() - } - } - - func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { - subscribedCentrals.removeAll { $0 == central } - - // Clear announce tracking for this central - sentAnnounceBackToCentral.remove(central.identifier) - - // Clean up the central to peer ID mapping - if let peerID = centralToPeerID[central.identifier] { - centralToPeerID.removeValue(forKey: central.identifier) - - // Check if this peer has any other connections - // If not, mark them as disconnected - collectionsQueue.async(flags: .barrier) { [weak self] in - guard let self = self else { return } - - // Only mark as disconnected if they don't have a peripheral connection - if self.connectedPeripherals[peerID] == nil, - let session = self.peerSessions[peerID] { - session.isConnected = false - SecureLogger.log("Central disconnected for peer \(peerID), marking as disconnected", - category: SecureLogger.session, level: .debug) - } - } - } - - // Ensure advertising continues for reconnection - if peripheralManager?.state == .poweredOn && peripheralManager?.isAdvertising == false { - startAdvertising() - } - } - - // MARK: - Battery Monitoring - - private func setupBatteryOptimizer() { - // Subscribe to power mode changes - batteryOptimizer.$currentPowerMode - .sink { [weak self] powerMode in - self?.handlePowerModeChange(powerMode) - } - .store(in: &batteryOptimizerCancellables) - - // Subscribe to battery level changes - batteryOptimizer.$batteryLevel - .sink { [weak self] level in - self?.currentBatteryLevel = level - } - .store(in: &batteryOptimizerCancellables) - - // Initial update - handlePowerModeChange(batteryOptimizer.currentPowerMode) - } - - private func handlePowerModeChange(_ powerMode: PowerMode) { - let params = batteryOptimizer.scanParameters - activeScanDuration = params.duration - scanPauseDuration = params.pause - - // Update max connections using dynamic calculation - let dynamicMaxConnections = calculateDynamicConnectionLimit() - - // If we have too many connections, disconnect from the least important ones - if connectedPeripherals.count > dynamicMaxConnections { - disconnectLeastImportantPeripherals(keepCount: dynamicMaxConnections) - } - - // If we're currently scanning, restart with new parameters - if scanDutyCycleTimer != nil { - scanDutyCycleTimer?.invalidate() - scheduleScanDutyCycle() - } - - // Handle advertising intervals - if powerMode.advertisingInterval > 0 { - // Stop continuous advertising and use interval-based - scheduleAdvertisingCycle(interval: powerMode.advertisingInterval) - } else { - // Continuous advertising for performance mode - startAdvertising() - } - } - - private func disconnectLeastImportantPeripherals(keepCount: Int) { - // Disconnect peripherals with lowest activity/importance - let sortedPeripherals = connectedPeripherals.values - .sorted { peer1, peer2 in - // Keep peripherals we've recently communicated with - let peer1Activity = lastMessageFromPeer.get(peer1.identifier.uuidString) ?? Date.distantPast - let peer2Activity = lastMessageFromPeer.get(peer2.identifier.uuidString) ?? Date.distantPast - return peer1Activity > peer2Activity - } - - // Disconnect the least active ones - let toDisconnect = sortedPeripherals.dropFirst(keepCount) - for peripheral in toDisconnect { - centralManager?.cancelPeripheralConnection(peripheral) - } - } - - private func scheduleAdvertisingCycle(interval: TimeInterval) { - advertisingTimer?.invalidate() - - // Stop advertising - if isAdvertising { - peripheralManager?.stopAdvertising() - isAdvertising = false - } - - // Schedule next advertising burst - advertisingTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in - self?.advertiseBurst() - } - } - - private func advertiseBurst() { - guard batteryOptimizer.currentPowerMode != .ultraLowPower || !batteryOptimizer.isInBackground else { - return // Skip advertising in ultra low power + background - } - - startAdvertising() - - // Stop advertising after a short burst (1 second) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - if self?.batteryOptimizer.currentPowerMode.advertisingInterval ?? 0 > 0 { - self?.peripheralManager?.stopAdvertising() - self?.isAdvertising = false - } - } - } - - // Legacy battery monitoring methods - kept for compatibility - // Now handled by BatteryOptimizer - private func updateBatteryLevel() { - // This method is now handled by BatteryOptimizer - // Keeping empty implementation for compatibility - } - - private func switchToBackgroundScanning() { - guard centralManager?.state == .poweredOn else { return } - - // Stop existing scanning and duty cycling - centralManager?.stopScan() - scanDutyCycleTimer?.invalidate() - scanDutyCycleTimer = nil - - // Use continuous scanning with battery-efficient options in background - let backgroundScanOptions: [String: Any] = [ - CBCentralManagerScanOptionAllowDuplicatesKey: false // Battery efficient - ] - - centralManager?.scanForPeripherals( - withServices: [BluetoothMeshService.serviceUUID], - options: backgroundScanOptions - ) - - SecureLogger.log("[SCAN-MODE] Switched to continuous background scanning", level: .info) - } - - private func switchToForegroundScanning() { - guard centralManager?.state == .poweredOn else { return } - - // Stop existing scanning - centralManager?.stopScan() - scanDutyCycleTimer?.invalidate() - scanDutyCycleTimer = nil - - // Use foreground scanning with duty cycling and allow duplicates for faster discovery - let foregroundScanOptions: [String: Any] = [ - CBCentralManagerScanOptionAllowDuplicatesKey: true // Faster discovery - ] - - centralManager?.scanForPeripherals( - withServices: [BluetoothMeshService.serviceUUID], - options: foregroundScanOptions - ) - - // Re-enable duty cycling for foreground operation - scheduleScanDutyCycle() - - SecureLogger.log("[SCAN-MODE] Switched to foreground scanning with duty cycling", level: .info) - } - - private func updateScanParametersForBattery() { - // This method is now handled by BatteryOptimizer through handlePowerModeChange - // Keeping empty implementation for compatibility - } - - // MARK: - Privacy Utilities - - private func randomDelay() -> TimeInterval { - // Generate random delay between min and max for timing obfuscation - return TimeInterval.random(in: minMessageDelay...maxMessageDelay) - } - - // MARK: - Range Optimization Methods - - - // Exponential delay distribution to prevent synchronized collision storms - private func exponentialRelayDelay() -> TimeInterval { - // Use exponential distribution with mean of (min + max) / 2 - let meanDelay = (minMessageDelay + maxMessageDelay) / 2.0 - let lambda = 1.0 / meanDelay - - // Generate exponential random value - let u = Double.random(in: 0..<1) - let exponentialDelay = -log(1.0 - u) / lambda - - // Clamp to our bounds - return min(maxMessageDelay, max(minMessageDelay, exponentialDelay)) - } - - // Check if message type is high priority - private func isHighPriorityMessage(type: UInt8) -> Bool { - switch MessageType(rawValue: type) { - case .noiseHandshakeInit, .noiseHandshakeResp, .protocolAck, - .deliveryAck, .systemValidation, .handshakeRequest: - return true - case .message, .announce, .leave, .readReceipt, .deliveryStatusRequest, - .fragmentStart, .fragmentContinue, .fragmentEnd, - .noiseIdentityAnnounce, .noiseEncrypted, .protocolNack, - .favorited, .unfavorited, .none: - return false - } - } - - // Calculate exponential backoff for retries - private func calculateExponentialBackoff(retry: Int) -> TimeInterval { - // Start with 1s, double each time: 1s, 2s, 4s, 8s, 16s, max 30s - let baseDelay = 1.0 - let maxDelay = 30.0 - let delay = min(maxDelay, baseDelay * pow(2.0, Double(retry - 1))) - - // Add 10% jitter to prevent synchronized retries - let jitter = delay * 0.1 * (Double.random(in: -1...1)) - return delay + jitter - } - - // MARK: - Collision Avoidance - - // Calculate jitter based on node ID to spread transmissions - private func calculateNodeIDJitter() -> TimeInterval { - // Use hash of peer ID to generate consistent jitter for this node - let hashValue = myPeerID.hash - let normalizedHash = Double(abs(hashValue % 1000)) / 1000.0 // 0.0 to 0.999 - - // Jitter range: 0-20ms based on node ID - let jitterRange: TimeInterval = 0.02 // 20ms max jitter - return normalizedHash * jitterRange - } - - // Add smart delay to avoid collisions - private func smartCollisionAvoidanceDelay(baseDelay: TimeInterval) -> TimeInterval { - // Add node-specific jitter to base delay - let nodeJitter = calculateNodeIDJitter() - - // Add small random component to avoid perfect synchronization - let randomJitter = TimeInterval.random(in: 0...0.005) // 0-5ms additional random - - return baseDelay + nodeJitter + randomJitter - } - - // MARK: - Relay Cancellation - - private func scheduleRelay(_ packet: BitchatPacket, messageID: String, delay: TimeInterval) { - pendingRelaysLock.lock() - defer { pendingRelaysLock.unlock() } - - // Cancel any existing relay for this message - if let existingRelay = pendingRelays[messageID] { - existingRelay.cancel() - } - - // Apply smart collision avoidance to delay - let adjustedDelay = smartCollisionAvoidanceDelay(baseDelay: delay) - - // Create new relay task - let relayTask = DispatchWorkItem { [weak self] in - guard let self = self else { return } - - // Remove from pending when executed - self.pendingRelaysLock.lock() - self.pendingRelays.removeValue(forKey: messageID) - self.pendingRelaysLock.unlock() - - // Mark this message as relayed - self.processedMessagesLock.lock() - if var state = self.processedMessages[messageID] { - state.relayed = true - self.processedMessages[messageID] = state - } - self.processedMessagesLock.unlock() - - // Actually relay the packet - self.broadcastPacket(packet) - } - - // Store the task - pendingRelays[messageID] = relayTask - - // Schedule it with adjusted delay - DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + adjustedDelay, execute: relayTask) - } - - private func cancelPendingRelay(messageID: String) { - pendingRelaysLock.lock() - defer { pendingRelaysLock.unlock() } - - if let pendingRelay = pendingRelays[messageID] { - pendingRelay.cancel() - pendingRelays.removeValue(forKey: messageID) - // Cancelled pending relay - another node handled it - } - } - - // MARK: - Memory Management - - private func startMemoryCleanupTimer() { - memoryCleanupTimer?.invalidate() - memoryCleanupTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in - self?.performMemoryCleanup() - } - } - - private func performMemoryCleanup() { - messageQueue.async(flags: .barrier) { [weak self] in - guard let self = self else { return } - - let startTime = Date() - - // Clean up old fragments - let fragmentTimeout = Date().addingTimeInterval(-self.fragmentTimeout) - var expiredFragments = [String]() - for (fragmentID, metadata) in self.fragmentMetadata { - if metadata.timestamp < fragmentTimeout { - expiredFragments.append(fragmentID) - } - } - for fragmentID in expiredFragments { - self.incomingFragments.removeValue(forKey: fragmentID) - self.fragmentMetadata.removeValue(forKey: fragmentID) - } - - // Limit pending private messages - if self.pendingPrivateMessages.count > self.maxPendingPrivateMessages { - let excess = self.pendingPrivateMessages.count - self.maxPendingPrivateMessages - let keysToRemove = Array(self.pendingPrivateMessages.keys.prefix(excess)) - for key in keysToRemove { - self.pendingPrivateMessages.removeValue(forKey: key) - } - SecureLogger.log("Removed \(excess) oldest pending private message queues", - category: SecureLogger.session, level: .info) - } - - // Clean up pending relays - self.pendingRelaysLock.lock() - _ = self.pendingRelays.count - self.pendingRelaysLock.unlock() - - // Clean up rate limiters - self.cleanupRateLimiters() - - // Log memory status - _ = Date().timeIntervalSince(startTime) - // Memory cleanup completed - - // Estimate current memory usage and log if high - let estimatedMemory = self.estimateMemoryUsage() - if estimatedMemory > self.maxMemoryUsageBytes { - SecureLogger.log("Warning: Estimated memory usage \(estimatedMemory / 1024 / 1024)MB exceeds limit", - category: SecureLogger.session, level: .warning) - } - } - } - - private func estimateMemoryUsage() -> Int { - // Rough estimates based on typical sizes - let messageSize = 512 // Average message size - let fragmentSize = 512 // Average fragment size - - var totalBytes = 0 - - // Processed messages (string storage + MessageState) - processedMessagesLock.lock() - totalBytes += processedMessages.count * 150 // messageID strings + MessageState struct - processedMessagesLock.unlock() - - // Fragments - for (_, fragments) in incomingFragments { - totalBytes += fragments.count * fragmentSize - } - - // Pending private messages - for (_, messages) in pendingPrivateMessages { - totalBytes += messages.count * messageSize - } - - // Other caches - totalBytes += deliveredMessages.count * 100 // messageID strings - totalBytes += recentlySentMessages.count * 100 // messageID strings - - return totalBytes - } - - // MARK: - Rate Limiting - - private func isRateLimited(peerID: String, messageType: UInt8) -> Bool { - rateLimiterLock.lock() - defer { rateLimiterLock.unlock() } - - let now = Date() - let cutoff = now.addingTimeInterval(-rateLimitWindow) - - // Clean old timestamps from total counter - totalMessageTimestamps.removeAll { $0 < cutoff } - - // Check global rate limit with progressive throttling - if totalMessageTimestamps.count >= maxTotalMessagesPerMinute { - // Apply progressive throttling for global limit - let overageRatio = Double(totalMessageTimestamps.count - maxTotalMessagesPerMinute) / Double(maxTotalMessagesPerMinute) - let dropProbability = min(0.95, 0.5 + overageRatio * 0.5) // 50% to 95% drop rate - - if Double.random(in: 0...1) < dropProbability { - SecureLogger.log("Global rate limit throttling: \(totalMessageTimestamps.count) messages, drop prob: \(Int(dropProbability * 100))%", - category: SecureLogger.security, level: .warning) - return true - } - } - - // Determine which rate limiter to use based on message type - let isChatMessage = messageType == MessageType.message.rawValue - - let limiter = isChatMessage ? messageRateLimiter : protocolMessageRateLimiter - let maxPerMinute = isChatMessage ? maxChatMessagesPerPeerPerMinute : maxProtocolMessagesPerPeerPerMinute - - // Clean old timestamps for this peer - if var timestamps = limiter[peerID] { - timestamps.removeAll { $0 < cutoff } - - // Update the appropriate limiter - if isChatMessage { - messageRateLimiter[peerID] = timestamps - } else { - protocolMessageRateLimiter[peerID] = timestamps - } - - // Progressive throttling for per-peer limit - if timestamps.count >= maxPerMinute { - // Calculate how much over the limit we are - let overageRatio = Double(timestamps.count - maxPerMinute) / Double(maxPerMinute) - - // Progressive drop probability: starts at 30% when just over limit, increases to 90% - let dropProbability = min(0.9, 0.3 + overageRatio * 0.6) - - if Double.random(in: 0...1) < dropProbability { - let messageTypeStr = isChatMessage ? "chat" : "protocol" - SecureLogger.log("Peer \(peerID) \(messageTypeStr) rate throttling: \(timestamps.count) msgs/min, drop prob: \(Int(dropProbability * 100))%", - category: SecureLogger.security, level: .info) - return true - } else { - SecureLogger.log("Peer \(peerID) rate throttling: allowing message (\(timestamps.count) msgs/min)", - category: SecureLogger.security, level: .debug) - } - } - } - - return false - } - - private func recordMessage(from peerID: String, messageType: UInt8) { - rateLimiterLock.lock() - defer { rateLimiterLock.unlock() } - - let now = Date() - - // Record in global counter - totalMessageTimestamps.append(now) - - // Determine which rate limiter to use based on message type - let isChatMessage = messageType == MessageType.message.rawValue - - // Record for specific peer in the appropriate limiter - if isChatMessage { - if messageRateLimiter[peerID] == nil { - messageRateLimiter[peerID] = [] - } - messageRateLimiter[peerID]?.append(now) - } else { - if protocolMessageRateLimiter[peerID] == nil { - protocolMessageRateLimiter[peerID] = [] - } - protocolMessageRateLimiter[peerID]?.append(now) - } - } - - private func cleanupRateLimiters() { - rateLimiterLock.lock() - defer { rateLimiterLock.unlock() } - - let cutoff = Date().addingTimeInterval(-rateLimitWindow) - - // Clean global timestamps - totalMessageTimestamps.removeAll { $0 < cutoff } - - // Clean up old identity announce tracking - processedMessagesLock.lock() - let identityCutoff = Date().addingTimeInterval(-identityAnnounceDuplicateWindow * 2) - recentIdentityAnnounces = recentIdentityAnnounces.filter { $0.value > identityCutoff } - processedMessagesLock.unlock() - - // Clean per-peer chat message timestamps - for (peerID, timestamps) in messageRateLimiter { - let filtered = timestamps.filter { $0 >= cutoff } - if filtered.isEmpty { - messageRateLimiter.removeValue(forKey: peerID) - } else { - messageRateLimiter[peerID] = filtered - } - } - - // Clean per-peer protocol message timestamps - for (peerID, timestamps) in protocolMessageRateLimiter { - let filtered = timestamps.filter { $0 >= cutoff } - if filtered.isEmpty { - protocolMessageRateLimiter.removeValue(forKey: peerID) - } else { - protocolMessageRateLimiter[peerID] = filtered - } - } - - SecureLogger.log("Rate limiter cleanup: tracking \(messageRateLimiter.count) chat peers, \(protocolMessageRateLimiter.count) protocol peers, \(totalMessageTimestamps.count) total messages", - category: SecureLogger.session, level: .debug) - } - - - private func updatePeerLastSeen(_ peerID: String) { - peerLastSeenTimestamps.set(peerID, value: Date()) - } - - private func sendPendingPrivateMessages(to peerID: String) { - messageQueue.async(flags: .barrier) { [weak self] in - guard let self = self else { return } - - // Get pending messages with proper queue synchronization - let pendingMessages = self.collectionsQueue.sync { - return self.pendingPrivateMessages[peerID] - } - - guard let messages = pendingMessages else { return } - - // Sending pending private messages - - // Clear pending messages for this peer - self.collectionsQueue.sync(flags: .barrier) { - _ = self.pendingPrivateMessages.removeValue(forKey: peerID) - } - - // Send each pending message - for (content, recipientNickname, messageID) in messages { - // Check if this is a read receipt - if content.hasPrefix("READ_RECEIPT:") { - // Extract the original message ID - let originalMessageID = String(content.dropFirst("READ_RECEIPT:".count)) - // Sending queued read receipt - - // Create and send the actual read receipt - let receipt = ReadReceipt( - originalMessageID: originalMessageID, - readerID: self.myPeerID, - readerNickname: recipientNickname // This is actually the reader's nickname - ) - - // Send the read receipt using the normal method - DispatchQueue.global().async { [weak self] in - self?.sendReadReceipt(receipt, to: peerID) - } - } else { - // Regular message - // Sending pending message - // Use async to avoid blocking the queue - DispatchQueue.global().async { [weak self] in - self?.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) - } - } - } - } - } - - // MARK: - Noise Protocol Support - - private func attemptHandshakeIfNeeded(with peerID: String, forceIfStale: Bool = false) { - // Check if we already have an established session - if noiseService.hasEstablishedSession(with: peerID) { - SecureLogger.log("Already have established session with \(peerID), skipping handshake", - category: SecureLogger.handshake, level: .debug) - return - } - - // Check if we should initiate using the handshake coordinator - if !handshakeCoordinator.shouldInitiateHandshake( - myPeerID: myPeerID, - remotePeerID: peerID, - forceIfStale: forceIfStale - ) { - SecureLogger.log("Should not initiate handshake with \(peerID) at this time", - category: SecureLogger.handshake, level: .debug) - return - } - - // Initiate the handshake - initiateNoiseHandshake(with: peerID) - } - - // Send keep-alive pings to all connected peers to prevent iOS BLE timeouts - private func sendKeepAlivePings() { - let connectedPeers = collectionsQueue.sync { - return self.peerSessions.compactMap { (peerID, session) -> String? in - guard session.isActivePeer else { return nil } - - // Only send keepalive to authenticated peers that still exist - // Check if this peer ID is still current (not rotated) - guard let fingerprint = peerIDToFingerprint[peerID], - let currentPeerID = fingerprintToPeerID[fingerprint], - currentPeerID == peerID else { - // This peer ID has rotated, skip it - return nil - } - - // Check if we actually have a Noise session with this peer - guard noiseService.hasEstablishedSession(with: peerID) else { - return nil - } - - return peerConnectionStates[peerID] == .authenticated ? peerID : nil - } - } - - - for peerID in connectedPeers { - // Don't spam if we recently heard from them - let lastHeard = self.peerSessions[peerID]?.lastHeardFromPeer - if let lastHeard = lastHeard, - Date().timeIntervalSince(lastHeard) < keepAliveInterval / 2 { - continue // Skip if we heard from them in the last 10 seconds - } - - validateNoiseSession(with: peerID) - } - } - - // Validate an existing Noise session by sending an encrypted ping - private func validateNoiseSession(with peerID: String) { - let encryptionQueue = getEncryptionQueue(for: peerID) - - encryptionQueue.async { [weak self] in - guard let self = self else { return } - - // Create a ping packet with minimal data - let pingData = "ping:\(Date().timeIntervalSince1970)".data(using: .utf8)! - - do { - // Try to encrypt a small ping message - let encrypted = try self.noiseService.encrypt(pingData, for: peerID) - - // Create a validation packet (won't be displayed to user) - let packet = BitchatPacket( - type: MessageType.systemValidation.rawValue, - senderID: Data(hexString: self.myPeerID) ?? Data(), - recipientID: Data(hexString: peerID), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encrypted, - signature: nil, - ttl: 1 ) - - // System validation should go directly to the peer when possible - if !self.sendDirectToRecipient(packet, recipientPeerID: peerID) { - // Fall back to selective relay if direct delivery fails - self.sendViaSelectiveRelay(packet, recipientPeerID: peerID) - } - - SecureLogger.log("Sent session validation ping to \(peerID)", - category: SecureLogger.session, level: .debug) - } catch { - // Encryption failed - session is invalid - SecureLogger.log("Session validation failed for \(peerID): \(error)", - category: SecureLogger.session, level: .warning) - - // Clear the invalid session - self.cleanupPeerCryptoState(peerID) - - // Initiate fresh handshake - DispatchQueue.main.async { [weak self] in - self?.attemptHandshakeIfNeeded(with: peerID, forceIfStale: true) - } - } - } - } - - private func initiateNoiseHandshake(with peerID: String) { - // Use noiseService directly - - // Initiating Noise handshake - - // Safety check: Don't handshake with ourselves - if peerID == myPeerID { - SecureLogger.log("[CRITICAL] Attempted to handshake with self! peerID=\(peerID), myPeerID=\(myPeerID)", - category: SecureLogger.handshake, level: .error) - return - } - - // Check if we already have an established session - if noiseService.hasEstablishedSession(with: peerID) { - // Already have established session - // Clear any lingering handshake attempt time - handshakeAttemptTimes.removeValue(forKey: peerID) - handshakeCoordinator.recordHandshakeSuccess(peerID: peerID) - - // Update session state to established - collectionsQueue.sync(flags: .barrier) { - self.noiseSessionStates[peerID] = .established - } - - // Update connection state to authenticated - updatePeerConnectionState(peerID, state: .authenticated) - - // Force UI update since we have an existing session - DispatchQueue.main.async { [weak self] in - (self?.delegate as? ChatViewModel)?.updateEncryptionStatusForPeers() - } - - return - } - - // Update state to handshaking - collectionsQueue.sync(flags: .barrier) { - self.noiseSessionStates[peerID] = .handshaking - } - - // Check if we have pending messages - let hasPendingMessages = collectionsQueue.sync { - return pendingPrivateMessages[peerID]?.isEmpty == false - } - - // Check with coordinator if we should initiate - if !handshakeCoordinator.shouldInitiateHandshake(myPeerID: myPeerID, remotePeerID: peerID, forceIfStale: hasPendingMessages) { - // Coordinator says no handshake - - if hasPendingMessages { - // Check if peer is still connected before retrying - let connectionState = collectionsQueue.sync { peerConnectionStates[peerID] ?? .disconnected } - - if connectionState == .disconnected { - // Peer is disconnected - clear pending messages and stop retrying - // Peer disconnected, clearing pending - collectionsQueue.async(flags: .barrier) { [weak self] in - self?.pendingPrivateMessages[peerID]?.removeAll() - } - handshakeCoordinator.resetHandshakeState(for: peerID) - } else { - // Peer is still connected but handshake is stuck - // Send identity announce to prompt them to initiate if they have lower ID - // Handshake stuck, sending identity - sendNoiseIdentityAnnounce(to: peerID) - - // Only retry if we haven't retried too many times - let retryCount = handshakeCoordinator.getRetryCount(for: peerID) - if retryCount < 3 { - handshakeCoordinator.incrementRetryCount(for: peerID) - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - self?.initiateNoiseHandshake(with: peerID) - } - } else { - SecureLogger.log("Max retries reached for \(peerID), clearing pending messages", category: SecureLogger.handshake, level: .warning) - collectionsQueue.async(flags: .barrier) { [weak self] in - self?.pendingPrivateMessages[peerID]?.removeAll() - } - handshakeCoordinator.resetHandshakeState(for: peerID) - } - } - } - return - } - - // Check if there's a retry delay - if let retryDelay = handshakeCoordinator.getRetryDelay(for: peerID), retryDelay > 0 { - // Waiting before retry - DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { [weak self] in - self?.initiateNoiseHandshake(with: peerID) - } - return - } - - // Record that we're initiating - handshakeCoordinator.recordHandshakeInitiation(peerID: peerID) - handshakeAttemptTimes[peerID] = Date() - - // Update connection state to authenticating - updatePeerConnectionState(peerID, state: .authenticating) - - do { - // Generate handshake initiation message - let handshakeData = try noiseService.initiateHandshake(with: peerID) - SecureLogger.logHandshake("initiated", peerID: peerID, success: true) - - // Send handshake initiation - let packet = BitchatPacket( - type: MessageType.noiseHandshakeInit.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), - recipientID: Data(hexString: peerID) ?? Data(), // Add recipient ID for targeted delivery - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: handshakeData, - signature: nil, - ttl: 6 // Increased TTL for better delivery on startup - ) - - // Track packet for ACK - trackPacketForAck(packet) - - // Try direct delivery first for handshake init - if !sendDirectToRecipient(packet, recipientPeerID: peerID) { - // Handshakes are critical - use broadcast as fallback to ensure delivery - SecureLogger.log("Recipient \(peerID) not directly connected for handshake init, using broadcast", - category: SecureLogger.session, level: .info) - broadcastPacket(packet) - } - - // Schedule a retry check after 5 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in - guard let self = self else { return } - // Check if handshake completed - if !self.noiseService.hasEstablishedSession(with: peerID) { - let state = self.handshakeCoordinator.getHandshakeState(for: peerID) - if case .initiating = state { - SecureLogger.log("Handshake with \(peerID) not completed after 5s, will retry", category: SecureLogger.handshake, level: .warning) - // The handshake coordinator will handle retry logic - } - } - } - - } catch NoiseSessionError.alreadyEstablished { - // Session already established, no need to handshake - handshakeCoordinator.recordHandshakeSuccess(peerID: peerID) - } catch { - // Failed to initiate handshake - handshakeCoordinator.recordHandshakeFailure(peerID: peerID, reason: error.localizedDescription) - SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription)) - } - } - - private func handleNoiseHandshakeMessage(from peerID: String, message: Data, isInitiation: Bool) { - // Use noiseService directly - SecureLogger.logHandshake("processing \(isInitiation ? "init" : "response")", peerID: peerID, success: true) - - // Get current handshake state before processing - // Current handshake state check - - // Check for duplicate handshake messages - if handshakeCoordinator.isDuplicateHandshakeMessage(message) { - // Duplicate handshake message, ignoring - return - } - - // If this is an initiation, check if we should accept it - if isInitiation { - if !handshakeCoordinator.shouldAcceptHandshakeInitiation(myPeerID: myPeerID, remotePeerID: peerID) { - // Coordinator says no accept - return - } - // Record that we're responding - handshakeCoordinator.recordHandshakeResponse(peerID: peerID) - - // Update connection state to authenticating - updatePeerConnectionState(peerID, state: .authenticating) - } - - do { - // Process handshake message - if let response = try noiseService.processHandshakeMessage(from: peerID, message: message) { - // Handshake response ready to send - - // Always send responses as handshake response type - let packet = BitchatPacket( - type: MessageType.noiseHandshakeResp.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), - recipientID: Data(hexString: peerID) ?? Data(), // Add recipient ID for targeted delivery - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: response, - signature: nil, - ttl: 6 // Increased TTL for better delivery on startup - ) - - // Track packet for ACK - trackPacketForAck(packet) - - // Try direct delivery first for handshake response - if !sendDirectToRecipient(packet, recipientPeerID: peerID) { - // Handshakes are critical - use broadcast as fallback to ensure delivery - SecureLogger.log("Recipient \(peerID) not directly connected for handshake response, using broadcast", - category: SecureLogger.session, level: .info) - broadcastPacket(packet) - } - } else { - // No response needed - } - - // Check if handshake is complete - let sessionEstablished = noiseService.hasEstablishedSession(with: peerID) - _ = handshakeCoordinator.getHandshakeState(for: peerID) - // Handshake state updated - - if sessionEstablished { - SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) - // Unlock rotation now that handshake is complete - unlockRotation() - - // Session established successfully - let nickname = collectionsQueue.sync { self.peerSessions[peerID]?.nickname ?? "Unknown" } - SecureLogger.log("✅ Successfully connected to peer \(peerID) (\(nickname))", - category: SecureLogger.session, level: .info) - handshakeCoordinator.recordHandshakeSuccess(peerID: peerID) - - // Update session state to established - collectionsQueue.sync(flags: .barrier) { - self.noiseSessionStates[peerID] = .established - } - - // Update connection state to authenticated - updatePeerConnectionState(peerID, state: .authenticated) - - // Clear handshake attempt time on success - handshakeAttemptTimes.removeValue(forKey: peerID) - - // Initialize last successful message time - updateLastSuccessfulMessageTime(peerID) - // Initialized message time - - // Update PeerSession - if let session = self.peerSessions[peerID] { - session.lastSuccessfulMessageTime = Date() - } else { - let nickname = self.getBestAvailableNickname(for: peerID) - let session = PeerSession(peerID: peerID, nickname: nickname) - session.lastSuccessfulMessageTime = Date() - self.peerSessions[peerID] = session - } - - // Send identity announcement to this specific peer - sendNoiseIdentityAnnounce(to: peerID) - - // Also broadcast to ensure all peers get it - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.sendNoiseIdentityAnnounce() - } - - // Send regular announce packet after handshake to trigger connect message - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in - self?.sendAnnouncementToPeer(peerID) - } - - // Send any pending private messages - self.sendPendingPrivateMessages(to: peerID) - } - } catch NoiseSessionError.alreadyEstablished { - // Session already established, ignore handshake - // Handshake already established - handshakeCoordinator.recordHandshakeSuccess(peerID: peerID) - - // Update session state to established - collectionsQueue.sync(flags: .barrier) { - self.noiseSessionStates[peerID] = .established - } - } catch { - // Handshake failed - handshakeCoordinator.recordHandshakeFailure(peerID: peerID, reason: error.localizedDescription) - SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription)) - SecureLogger.log("Handshake failed with \(peerID): \(error)", category: SecureLogger.noise, level: .error) - - // Update session state to failed - collectionsQueue.sync(flags: .barrier) { - self.noiseSessionStates[peerID] = .failed(error) - } - - // If handshake failed due to authentication error, clear the session to allow retry - if case NoiseError.authenticationFailure = error { - SecureLogger.log("Handshake failed with \(peerID): authenticationFailure - clearing session", category: SecureLogger.noise, level: .warning) - cleanupPeerCryptoState(peerID) - } - } - } - - private func handleNoiseEncryptedMessage(from peerID: String, encryptedData: Data, originalPacket: BitchatPacket, peripheral: CBPeripheral? = nil) { - // Use noiseService directly - - // For Noise encrypted messages, we need to decrypt first to check the inner packet - // The outer packet's recipientID might be for routing, not the final recipient - - // Create unique identifier for this encrypted message - let messageHash = encryptedData.prefix(32).hexEncodedString() // Use first 32 bytes as identifier - let messageKey = "\(peerID)-\(messageHash)" - - // Check if we've already processed this exact encrypted message - let alreadyProcessed = collectionsQueue.sync(flags: .barrier) { - if processedNoiseMessages.contains(messageKey) { - return true - } - processedNoiseMessages.insert(messageKey) - return false - } - - if alreadyProcessed { - return - } - - do { - // Decrypt the message - // Attempting to decrypt - let decryptedData = try noiseService.decrypt(encryptedData, from: peerID) - // Successfully decrypted message - - // Update last successful message time - updateLastSuccessfulMessageTime(peerID) - - // Note: PeerSession already updated in helper - if let session = self.peerSessions[peerID] { - session.lastSuccessfulMessageTime = Date() - } - - // Send protocol ACK after successful decryption (only once per encrypted packet) - sendProtocolAck(for: originalPacket, to: peerID) - - // If we can decrypt messages from this peer, they should be marked as active - let wasAdded = collectionsQueue.sync(flags: .barrier) { - let isActive = self.peerSessions[peerID]?.isActivePeer ?? false - if !isActive { - // Marking peer as active - // Update PeerSession - if let session = self.peerSessions[peerID] { - session.isActivePeer = true - } else { - let nickname = self.getBestAvailableNickname(for: peerID) - let session = PeerSession(peerID: peerID, nickname: nickname) - session.isActivePeer = true - self.peerSessions[peerID] = session - } - // Mark as active and return true if newly activated - let wasActive = self.peerSessions[peerID]?.isActivePeer ?? false - self.peerSessions[peerID]?.isActivePeer = true - return !wasActive - } - return false - } - - if wasAdded { - // Notify about peer list update - self.notifyPeerListUpdate() - } - - // Check if this is a special format message (type marker + payload) - if decryptedData.count > 1 { - let typeMarker = decryptedData[0] - - // Check if this is a delivery ACK with the new format - if typeMarker == MessageType.deliveryAck.rawValue { - // Extract the ACK JSON data (skip the type marker) - let ackData = decryptedData.dropFirst() - - // Decode the delivery ACK - try binary first, then JSON - if let ack = DeliveryAck.fromBinaryData(ackData) { - // Received binary delivery ACK - - // Process the ACK - DeliveryTracker.shared.processDeliveryAck(ack) - - // Notify delegate - DispatchQueue.main.async { - self.delegate?.didReceiveDeliveryAck(ack) - } - return - } else if let ack = DeliveryAck.decode(from: ackData) { - // Received JSON delivery ACK - - // Process the ACK - DeliveryTracker.shared.processDeliveryAck(ack) - - // Notify delegate - DispatchQueue.main.async { - self.delegate?.didReceiveDeliveryAck(ack) - } - return - } else { - SecureLogger.log("Failed to decode delivery ACK via Noise - data size: \(ackData.count)", category: SecureLogger.session, level: .warning) - } - } - - // Check if this is a read receipt with the new format - else if typeMarker == MessageType.readReceipt.rawValue { - // Extract the receipt binary data (skip the type marker) - let receiptData = decryptedData.dropFirst() - - // Decode the read receipt from binary - if let receipt = ReadReceipt.fromBinaryData(receiptData) { - // Received binary read receipt - - // Process the read receipt - DispatchQueue.main.async { - self.delegate?.didReceiveReadReceipt(receipt) - } - return - } else { - SecureLogger.log("Failed to decode read receipt via Noise - data size: \(receiptData.count)", category: SecureLogger.session, level: .warning) - } - } - } - - // Try to parse as a full inner packet (for backward compatibility and other message types) - if let innerPacket = BitchatPacket.from(decryptedData) { - // Successfully parsed inner packet - - // Process the decrypted inner packet - // The packet will be handled according to its recipient ID - // If it's for us, it won't be relayed - // Pass the peripheral context for proper ACK routing - handleReceivedPacket(innerPacket, from: peerID, peripheral: peripheral, decrypted: true) - } else { - SecureLogger.log("Failed to parse inner packet from decrypted data", category: SecureLogger.encryption, level: .warning) - } - } catch { - // Failed to decrypt - might need to re-establish session - SecureLogger.log("Failed to decrypt Noise message from \(peerID): \(error)", category: SecureLogger.encryption, level: .error) - if !noiseService.hasEstablishedSession(with: peerID) { - // No Noise session, attempting handshake - attemptHandshakeIfNeeded(with: peerID, forceIfStale: true) - } else { - SecureLogger.log("Have session with \(peerID) but decryption failed", category: SecureLogger.encryption, level: .warning) - - // Send a NACK to inform peer that decryption failed - sendProtocolNack(for: originalPacket, to: peerID, - reason: "Decryption failed", - errorCode: .decryptionFailed) - - // The NACK handler will take care of clearing sessions and re-establishing - // Don't initiate anything here to avoid race conditions - - // Update UI to show encryption is broken - DispatchQueue.main.async { [weak self] in - if let chatVM = self?.delegate as? ChatViewModel { - chatVM.updateEncryptionStatusForPeer(peerID) - } - } - } - } - } - - - // MARK: - Protocol Version Negotiation - - - - - - private func getPlatformString() -> String { - #if os(iOS) - return "iOS" - #elseif os(macOS) - return "macOS" - #else - return "Unknown" - #endif - } - - // MARK: - Protocol ACK/NACK Handling - - private func handleProtocolAck(from peerID: String, data: Data) { - guard let ack = ProtocolAck.fromBinaryData(data) else { - SecureLogger.log("Failed to decode protocol ACK from \(peerID)", category: SecureLogger.session, level: .error) - return - } - - SecureLogger.log("Received protocol ACK from \(peerID) for packet \(ack.originalPacketID), type: \(ack.packetType), ackID: \(ack.ackID)", - category: SecureLogger.session, level: .debug) - - // Remove from pending ACKs and mark as acknowledged - // Note: readUUID returns uppercase, but we track with lowercase - let normalizedPacketID = ack.originalPacketID.lowercased() - _ = collectionsQueue.sync(flags: .barrier) { - pendingAcks.removeValue(forKey: normalizedPacketID) - } - - // Track this packet as acknowledged to prevent future retries - acknowledgedPacketsLock.lock() - acknowledgedPackets.insert(ack.originalPacketID) - // Keep only recent acknowledged packets (last 1000) - if acknowledgedPackets.count > 1000 { - // Remove oldest entries (this is approximate since Set doesn't maintain order) - acknowledgedPackets = Set(Array(acknowledgedPackets).suffix(1000)) - } - acknowledgedPacketsLock.unlock() - - // Handle specific packet types that need ACK confirmation - if let messageType = MessageType(rawValue: ack.packetType) { - switch messageType { - case .noiseHandshakeInit, .noiseHandshakeResp: - // Handshake confirmed - break - case .noiseEncrypted: - // Encrypted message confirmed - break - default: - break - } - } - } - - private func handleProtocolNack(from peerID: String, data: Data) { - guard let nack = ProtocolNack.fromBinaryData(data) else { - SecureLogger.log("Failed to decode protocol NACK from \(peerID)", category: SecureLogger.session, level: .error) - return - } - - SecureLogger.log("Received protocol NACK from \(peerID) for packet \(nack.originalPacketID): \(nack.reason)", - category: SecureLogger.session, level: .warning) - - - // Remove from pending ACKs - _ = collectionsQueue.sync(flags: .barrier) { - pendingAcks.removeValue(forKey: nack.originalPacketID) - } - - // Handle specific error codes - if let errorCode = ProtocolNack.ErrorCode(rawValue: nack.errorCode) { - switch errorCode { - case .decryptionFailed: - // Session is out of sync - both sides need to clear and re-establish - SecureLogger.log("Decryption failed at \(peerID), clearing session and re-establishing", - category: SecureLogger.encryption, level: .warning) - - // Clear our session state and handshake coordinator state - cleanupPeerCryptoState(peerID) - handshakeCoordinator.resetHandshakeState(for: peerID) - - // Update connection state - updatePeerConnectionState(peerID, state: .connected) - - // Use deterministic role assignment to prevent race conditions - let shouldInitiate = handshakeCoordinator.determineHandshakeRole( - myPeerID: myPeerID, - remotePeerID: peerID - ) == .initiator - - if shouldInitiate { - // Small delay to ensure both sides have cleared state - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self = self else { return } - SecureLogger.log("Initiating handshake after decryption failure with \(peerID)", - category: SecureLogger.session, level: .info) - self.attemptHandshakeIfNeeded(with: peerID, forceIfStale: true) - } - } else { - // Send identity announcement to signal we're ready - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self = self else { return } - SecureLogger.log("Sending identity announcement after decryption failure to \(peerID)", - category: SecureLogger.session, level: .info) - self.sendNoiseIdentityAnnounce(to: peerID) - } - } - case .sessionExpired: - // Clear session and re-handshake - SecureLogger.log("Session expired at \(peerID), clearing and re-handshaking", - category: SecureLogger.session, level: .warning) - cleanupPeerCryptoState(peerID) - handshakeCoordinator.resetHandshakeState(for: peerID) - attemptHandshakeIfNeeded(with: peerID, forceIfStale: true) - default: - break - } - } - } - - private func handleHandshakeRequest(from peerID: String, data: Data) { - guard let request = HandshakeRequest.fromBinaryData(data) else { - SecureLogger.log("Failed to decode handshake request from \(peerID)", category: SecureLogger.noise, level: .error) - return - } - - // Verify this request is for us - guard request.targetID == myPeerID else { - // This request is not for us, might need to relay - return - } - - SecureLogger.log("Received handshake request from \(request.requesterID) (\(request.requesterNickname)) with \(request.pendingMessageCount) pending messages", - category: SecureLogger.noise, level: .info) - - // Don't show handshake request notification in UI - // User requested to remove this notification - /* - DispatchQueue.main.async { [weak self] in - if let chatVM = self?.delegate as? ChatViewModel { - // Notify the UI that someone wants to send messages - chatVM.handleHandshakeRequest(from: request.requesterID, - nickname: request.requesterNickname, - pendingCount: request.pendingMessageCount) - } - } - */ - - // Check if we already have a session - if noiseService.hasEstablishedSession(with: peerID) { - // We already have a session, no action needed - // Already have session, ignoring request - return - } - - // Apply tie-breaker logic for handshake initiation - if myPeerID < peerID { - // We have lower ID, initiate handshake - // Initiating handshake in response - initiateNoiseHandshake(with: peerID) - } else { - // We have higher ID, send identity announce to prompt them - // Sending identity announce - sendNoiseIdentityAnnounce(to: peerID) - } - } - - // Send protocol ACK for important packets - private func sendProtocolAck(for packet: BitchatPacket, to peerID: String, hopCount: UInt8 = 0) { - // Generate packet ID from packet content hash - let packetID = generatePacketID(for: packet) - - // Debug: log packet details - _ = packet.senderID.prefix(4).hexEncodedString() - _ = packet.recipientID?.prefix(4).hexEncodedString() ?? "nil" - // Send protocol ACK - - let ack = ProtocolAck( - originalPacketID: packetID, - senderID: packet.senderID.hexEncodedString(), - receiverID: myPeerID, - packetType: packet.type, - hopCount: hopCount - ) - - let ackPacket = BitchatPacket( - type: MessageType.protocolAck.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), - recipientID: Data(hexString: peerID) ?? Data(), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: ack.toBinaryData(), - signature: nil, - ttl: 3 // ACKs don't need to travel far - ) - - // Protocol ACKs should go directly to the sender when possible - if !sendDirectToRecipient(ackPacket, recipientPeerID: peerID) { - // Fall back to selective relay if direct delivery fails - sendViaSelectiveRelay(ackPacket, recipientPeerID: peerID) - } - } - - // Send protocol NACK for failed packets - private func sendProtocolNack(for packet: BitchatPacket, to peerID: String, reason: String, errorCode: ProtocolNack.ErrorCode) { - let packetID = generatePacketID(for: packet) - - let nack = ProtocolNack( - originalPacketID: packetID, - senderID: packet.senderID.hexEncodedString(), - receiverID: myPeerID, - packetType: packet.type, - reason: reason, - errorCode: errorCode - ) - - let nackPacket = BitchatPacket( - type: MessageType.protocolNack.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), - recipientID: Data(hexString: peerID) ?? Data(), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: nack.toBinaryData(), - signature: nil, - ttl: 3 // NACKs don't need to travel far - ) - - // Protocol NACKs should go directly to the sender when possible - if !sendDirectToRecipient(nackPacket, recipientPeerID: peerID) { - // Fall back to selective relay if direct delivery fails - sendViaSelectiveRelay(nackPacket, recipientPeerID: peerID) - } - } - - // Generate unique packet ID from immutable packet fields - private func generatePacketID(for packet: BitchatPacket) -> String { - // Use only immutable fields for ID generation to ensure consistency - // across network hops (TTL changes, so can't use full packet data) - - // Create a deterministic ID using SHA256 of immutable fields - var data = Data() - data.append(packet.senderID) - data.append(contentsOf: withUnsafeBytes(of: packet.timestamp) { Array($0) }) - data.append(packet.type) - // Add first 32 bytes of payload for uniqueness - data.append(packet.payload.prefix(32)) - - let hash = SHA256.hash(data: data) - let hashData = Data(hash) - - // Take first 16 bytes for UUID format - let bytes = Array(hashData.prefix(16)) - - // Format as UUID - let p1 = String(format: "%02x%02x%02x%02x", bytes[0], bytes[1], bytes[2], bytes[3]) - let p2 = String(format: "%02x%02x", bytes[4], bytes[5]) - let p3 = String(format: "%02x%02x", bytes[6], bytes[7]) - let p4 = String(format: "%02x%02x", bytes[8], bytes[9]) - let p5 = String(format: "%02x%02x%02x%02x%02x%02x", bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]) - - let result = "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)" - - // Generated packet ID for tracking - - return result - } - - // Track packets that need ACKs - private func trackPacketForAck(_ packet: BitchatPacket) { - let packetID = generatePacketID(for: packet) - - // Debug: log packet details - _ = packet.senderID.prefix(4).hexEncodedString() - _ = packet.recipientID?.prefix(4).hexEncodedString() ?? "nil" - // Track packet for ACK - - collectionsQueue.sync(flags: .barrier) { - pendingAcks[packetID] = (packet: packet, timestamp: Date(), retries: 0) - } - - // Schedule timeout check with initial delay (using exponential backoff starting at 1s) - let initialDelay = calculateExponentialBackoff(retry: 1) // 1 second initial delay - DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) { [weak self] in - self?.checkAckTimeout(for: packetID) - } - } - - // Check for ACK timeout and retry if needed - private func checkAckTimeout(for packetID: String) { - // Check if already acknowledged - acknowledgedPacketsLock.lock() - let isAcknowledged = acknowledgedPackets.contains(packetID) - acknowledgedPacketsLock.unlock() - - if isAcknowledged { - // Already acknowledged, remove from pending and don't retry - _ = collectionsQueue.sync(flags: .barrier) { - pendingAcks.removeValue(forKey: packetID) - } - SecureLogger.log("Packet \(packetID) already acknowledged, cancelling retries", - category: SecureLogger.session, level: .debug) - return - } - - collectionsQueue.sync(flags: .barrier) { [weak self] in - guard let self = self, - let pending = self.pendingAcks[packetID] else { return } - - // Check if this is a handshake packet and we already have an established session - if pending.packet.type == MessageType.noiseHandshakeInit.rawValue || - pending.packet.type == MessageType.noiseHandshakeResp.rawValue { - // Extract peer ID from packet - let peerID = pending.packet.recipientID?.hexEncodedString() ?? "" - if !peerID.isEmpty && self.noiseService.hasEstablishedSession(with: peerID) { - // We have an established session, don't retry handshake packets - SecureLogger.log("Not retrying handshake packet \(packetID) - session already established with \(peerID)", - category: SecureLogger.handshake, level: .info) - self.pendingAcks.removeValue(forKey: packetID) - return - } - } - - if pending.retries < self.maxAckRetries { - // Retry sending the packet - SecureLogger.log("ACK timeout for packet \(packetID), retrying (attempt \(pending.retries + 1))", - category: SecureLogger.session, level: .warning) - - self.pendingAcks[packetID] = (packet: pending.packet, - timestamp: Date(), - retries: pending.retries + 1) - - // Resend the packet - SecureLogger.log("Resending packet due to ACK timeout (retry \(pending.retries + 1))", - category: SecureLogger.session, level: .debug) - DispatchQueue.main.async { - self.broadcastPacket(pending.packet) - } - - // Schedule next timeout check with exponential backoff - let backoffDelay = self.calculateExponentialBackoff(retry: pending.retries + 1) - DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) { - self.checkAckTimeout(for: packetID) - } - } else { - // Max retries reached, give up - SecureLogger.log("Max ACK retries reached for packet \(packetID), giving up", - category: SecureLogger.session, level: .error) - self.pendingAcks.removeValue(forKey: packetID) - - // Could notify upper layer about delivery failure here - } - } - } - - // Check all pending ACKs for timeouts (called by timer) - private func checkAckTimeouts() { - let now = Date() - var timedOutPackets: [String] = [] - - collectionsQueue.sync { - for (packetID, pending) in pendingAcks { - if now.timeIntervalSince(pending.timestamp) > ackTimeout { - timedOutPackets.append(packetID) - } - } - } - - // Process timeouts outside the sync block - for packetID in timedOutPackets { - checkAckTimeout(for: packetID) - } - } - - // Check peer availability based on last heard time - private func checkPeerAvailability() { - let now = Date() - var stateChanges: [(peerID: String, available: Bool)] = [] - - collectionsQueue.sync(flags: .barrier) { - // Check all active peers - for (peerID, session) in peerSessions where session.isActivePeer { - // Get last heard time from PeerSession or legacy tracking - let lastHeard: Date - if let session = peerSessions[peerID] { - lastHeard = session.lastHeardFromPeer ?? Date.distantPast - } else { - lastHeard = self.peerSessions[peerID]?.lastHeardFromPeer ?? Date.distantPast - } - - let timeSinceLastHeard = now.timeIntervalSince(lastHeard) - let wasAvailable = peerSessions[peerID]?.isAvailable ?? true - - // Check connection state - let connectionState = peerConnectionStates[peerID] ?? .disconnected - let hasConnection = connectionState == .connected || connectionState == .authenticating || connectionState == .authenticated - - // Peer is available if: - // 1. We have an active connection (regardless of last heard time), OR - // 2. We're authenticated and heard from them within timeout period - let isAvailable = hasConnection || - (connectionState == .authenticated && timeSinceLastHeard < peerAvailabilityTimeout) - - if wasAvailable != isAvailable { - // Availability is now tracked in PeerSession - - // Update PeerSession availability - if let session = peerSessions[peerID] { - session.isAvailable = isAvailable - } else { - // Create session if needed - let nickname = self.getBestAvailableNickname(for: peerID) - let session = PeerSession(peerID: peerID, nickname: nickname) - session.isAvailable = isAvailable - peerSessions[peerID] = session - } - - stateChanges.append((peerID: peerID, available: isAvailable)) - } - } - - // Remove availability state for peers no longer active - // Availability state cleanup no longer needed - tracked in PeerSession - } - - // Notify about availability changes - for change in stateChanges { - SecureLogger.log("Peer \(change.peerID) availability changed to: \(change.available)", - category: SecureLogger.session, level: .info) - - // Notify delegate about availability change - DispatchQueue.main.async { [weak self] in - self?.delegate?.peerAvailabilityChanged(change.peerID, available: change.available) - } - } - } - - // Update peer availability when we hear from them - private func updatePeerAvailability(_ peerID: String) { - updateLastHeardFromPeer(peerID) - - collectionsQueue.sync(flags: .barrier) { - // Note: lastHeardFromPeer already updated in helper above - var needsNotification = false - if let session = self.peerSessions[peerID] { - session.lastHeardFromPeer = Date() - if !session.isAvailable { - session.isAvailable = true - needsNotification = true - } - } - // Don't create sessions without peripheral connections - // No notification needed if we're not tracking this peer yet - - // Update legacy state - if peerSessions[peerID]?.isAvailable != true { - // Availability updated in PeerSession already - needsNotification = true - } - - if needsNotification { - SecureLogger.log("Peer \(peerID) marked as available after hearing from them", - category: SecureLogger.session, level: .info) - - DispatchQueue.main.async { [weak self] in - self?.delegate?.peerAvailabilityChanged(peerID, available: true) - } - } - } - } - - // Check if a peer is currently available - func isPeerAvailable(_ peerID: String) -> Bool { - return collectionsQueue.sync { - // Check PeerSession first - if let session = peerSessions[peerID] { - return session.isAvailable - } - // Fallback to legacy state - return false // If no session exists, peer is not available - } - } - - private func sendNoiseIdentityAnnounce(to specificPeerID: String? = nil) { - // Rate limit identity announcements - let now = Date() - - // If targeting a specific peer, check rate limit - if let peerID = specificPeerID { - if let lastTime = lastIdentityAnnounceTimes[peerID], - now.timeIntervalSince(lastTime) < identityAnnounceMinInterval { - // Too soon, skip this announcement - return - } - lastIdentityAnnounceTimes[peerID] = now - } else { - // Broadcasting to all - check global rate limit - if let lastTime = lastIdentityAnnounceTimes["*broadcast*"], - now.timeIntervalSince(lastTime) < identityAnnounceMinInterval { - return - } - lastIdentityAnnounceTimes["*broadcast*"] = now - } - - // Get our Noise static public key and signing public key - let staticKey = noiseService.getStaticPublicKeyData() - let signingKey = noiseService.getSigningPublicKeyData() - - // Get nickname from delegate - let nickname = (delegate as? ChatViewModel)?.nickname ?? "Anonymous" - - // Create the binding data to sign (peerID + publicKey + timestamp) - let timestampData = String(Int64(now.timeIntervalSince1970 * 1000)).data(using: .utf8)! - let bindingData = myPeerID.data(using: .utf8)! + staticKey + timestampData - - // Sign the binding with our Ed25519 signing key - let signature = noiseService.signData(bindingData) ?? Data() - - // Create the identity announcement - let announcement = NoiseIdentityAnnouncement( - peerID: myPeerID, - publicKey: staticKey, - signingPublicKey: signingKey, - nickname: nickname, - timestamp: now, - previousPeerID: nil, - signature: signature - ) - - // Encode the announcement - let announcementData = announcement.toBinaryData() - - let packet = BitchatPacket( - type: MessageType.noiseIdentityAnnounce.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), - recipientID: specificPeerID.flatMap { Data(hexString: $0) }, // Targeted or broadcast - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: announcementData, - signature: nil, - ttl: adaptiveTTL ) - - if let targetPeer = specificPeerID { - SecureLogger.log("Sending targeted identity announce to \(targetPeer)", - category: SecureLogger.noise, level: .info) - - // Try direct delivery for targeted announces - if !sendDirectToRecipient(packet, recipientPeerID: targetPeer) { - // Fall back to selective relay if direct delivery fails - SecureLogger.log("Recipient \(targetPeer) not directly connected for identity announce, using relay", - category: SecureLogger.session, level: .info) - sendViaSelectiveRelay(packet, recipientPeerID: targetPeer) - } - } else { - SecureLogger.log("Broadcasting identity announce to all peers", - category: SecureLogger.noise, level: .info) - broadcastPacket(packet) - } - } - - // Removed sendPacket method - all packets should use broadcastPacket to ensure mesh delivery - - // Send private message using Noise Protocol - private func sendPrivateMessageViaNoise(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) { - SecureLogger.log("sendPrivateMessageViaNoise called - content: '\(content.prefix(50))...', to: \(recipientPeerID), messageID: \(messageID ?? "nil")", - category: SecureLogger.noise, level: .info) - - // Use per-peer encryption queue to prevent nonce desynchronization - let encryptionQueue = getEncryptionQueue(for: recipientPeerID) - - encryptionQueue.async { [weak self] in - guard let self = self else { return } - - // Use noiseService directly - - // Check if we have a Noise session with this peer - let hasSession = self.noiseService.hasEstablishedSession(with: recipientPeerID) - - // Check if session is stale (no successful communication for a while) - var sessionIsStale = false - if hasSession { - let lastSuccess = self.peerSessions[recipientPeerID]?.lastSuccessfulMessageTime ?? Date.distantPast - let sessionAge = Date().timeIntervalSince(lastSuccess) - // Increase session validity to 24 hours - sessions should persist across temporary disconnects - if sessionAge > 86400.0 { // More than 24 hours since last successful message - sessionIsStale = true - // Session stale, will re-establish - } - } - - if !hasSession || sessionIsStale { - if sessionIsStale { - // Clear stale session first - cleanupPeerCryptoState(recipientPeerID) - } - - // Update state to handshakeQueued - collectionsQueue.sync(flags: .barrier) { - self.noiseSessionStates[recipientPeerID] = .handshakeQueued - } - - // No valid session, initiating handshake - - // Notify UI that we're establishing encryption - DispatchQueue.main.async { [weak self] in - if let chatVM = self?.delegate as? ChatViewModel { - // This will update the UI to show "establishing encryption" state - chatVM.updateEncryptionStatusForPeer(recipientPeerID) - } - } - - // Queue message for sending after handshake completes - collectionsQueue.sync(flags: .barrier) { [weak self] in - guard let self = self else { return } - if self.pendingPrivateMessages[recipientPeerID] == nil { - self.pendingPrivateMessages[recipientPeerID] = [] - } - self.pendingPrivateMessages[recipientPeerID]?.append((content, recipientNickname, messageID ?? UUID().uuidString)) - // Queued private message - } - - // Send handshake request to notify recipient of pending messages - sendHandshakeRequest(to: recipientPeerID, pendingCount: UInt8(pendingPrivateMessages[recipientPeerID]?.count ?? 1)) - - // Apply tie-breaker logic for handshake initiation - if myPeerID < recipientPeerID { - // We have lower ID, initiate handshake - initiateNoiseHandshake(with: recipientPeerID) - } else { - // We have higher ID, send targeted identity announce to prompt them to initiate - sendNoiseIdentityAnnounce(to: recipientPeerID) - } - - return - } - - // Use provided message ID or generate a new one - let msgID = messageID ?? UUID().uuidString - - // Check if we're already processing this message - let sendKey = "\(msgID)-\(recipientPeerID)" - let alreadySending = self.collectionsQueue.sync(flags: .barrier) { - if self.recentlySentMessages.contains(sendKey) { - return true - } - self.recentlySentMessages.insert(sendKey) - // Clean up old entries after 10 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in - self?.collectionsQueue.sync(flags: .barrier) { - _ = self?.recentlySentMessages.remove(sendKey) - } - } - return false - } - - if alreadySending { - return - } - - - // Get sender nickname from delegate - let nickname = self.delegate as? ChatViewModel - let senderNick = nickname?.nickname ?? self.myPeerID - - // Create the inner message - let message = BitchatMessage( - id: msgID, - sender: senderNick, - content: content, - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: recipientNickname, - senderPeerID: myPeerID - ) - - // Use binary payload format to match the receiver's expectations - guard let messageData = message.toBinaryPayload() else { - return - } - - // Create inner packet - let innerPacket = BitchatPacket( - type: MessageType.message.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), - recipientID: Data(hexString: recipientPeerID) ?? Data(), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: messageData, - signature: nil, - ttl: self.adaptiveTTL // Inner packet needs valid TTL for processing after decryption - ) - - guard let innerData = innerPacket.toBinaryData() else { return } - - do { - // Encrypt with Noise - // Encrypting private message - let encryptedData = try noiseService.encrypt(innerData, for: recipientPeerID) - // Successfully encrypted - - // Update last successful message time - updateLastSuccessfulMessageTime(recipientPeerID) - - // Note: PeerSession already updated in helper - if let session = peerSessions[recipientPeerID] { - session.lastSuccessfulMessageTime = Date() - } - - // Send as Noise encrypted message - let outerPacket = BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), - recipientID: Data(hexString: recipientPeerID) ?? Data(), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encryptedData, - signature: nil, - ttl: adaptiveTTL ) - - // Sending encrypted private message - - // Track packet for ACK - trackPacketForAck(outerPacket) - - // Try direct delivery first - if !sendDirectToRecipient(outerPacket, recipientPeerID: recipientPeerID) { - // Recipient not directly connected, use selective relay - SecureLogger.log("Recipient \(recipientPeerID) not directly connected, using relay strategy", - category: SecureLogger.session, level: .info) - sendViaSelectiveRelay(outerPacket, recipientPeerID: recipientPeerID) - } - } catch { - // Failed to encrypt message - SecureLogger.log("Failed to encrypt private message \(msgID) for \(recipientPeerID): \(error)", category: SecureLogger.encryption, level: .error) - } - } // End of encryptionQueue.async - } - - // MARK: - Targeted Message Delivery - - private func sendDirectToRecipient(_ packet: BitchatPacket, recipientPeerID: String) -> Bool { - // Try to send directly to the recipient if they're connected as peripheral (we're central) - if let peripheral = connectedPeripherals[recipientPeerID], - let characteristic = peripheralCharacteristics[peripheral], - peripheral.state == .connected { - - guard let data = packet.toBinaryData() else { return false } - - // Send only to the intended recipient - writeToPeripheral(data, peripheral: peripheral, characteristic: characteristic, peerID: recipientPeerID) - SecureLogger.log("Sent message directly to peripheral \(recipientPeerID)", - category: SecureLogger.session, level: .debug) - return true - } - - // Check if recipient is connected as a central (we're peripheral) - // Find the central that corresponds to this peer ID - for (centralID, peerID) in centralToPeerID { - if peerID == recipientPeerID { - // Found the central for this peer - if let central = subscribedCentrals.first(where: { $0.identifier == centralID }), - let characteristic = self.characteristic { - - guard let data = packet.toBinaryData() else { return false } - - // Send to specific central - peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) - SecureLogger.log("Sent message directly to central \(recipientPeerID) (\(centralID.uuidString.prefix(8)))", - category: SecureLogger.session, level: .debug) - return true - } - } - } - - return false - } - - private func sendHandshakeRequest(to targetPeerID: String, pendingCount: UInt8) { - // Create handshake request - let request = HandshakeRequest(requesterID: myPeerID, - requesterNickname: (delegate as? ChatViewModel)?.nickname ?? myPeerID, - targetID: targetPeerID, - pendingMessageCount: pendingCount) - - let requestData = request.toBinaryData() - - // Create packet for handshake request - let packet = BitchatPacket(type: MessageType.handshakeRequest.rawValue, - ttl: 6, - senderID: myPeerID, - payload: requestData) - - // Try direct delivery first - if sendDirectToRecipient(packet, recipientPeerID: targetPeerID) { - // Sent handshake directly - } else { - // Use selective relay if direct delivery fails - sendViaSelectiveRelay(packet, recipientPeerID: targetPeerID) - // Sent handshake via relay - } - } - - private func selectBestRelayPeers(excluding: String, maxPeers: Int = 3) -> [String] { - // Select random peers for relay, excluding the target recipient - var candidates: [String] = [] - - for (peerID, _) in connectedPeripherals { - if peerID != excluding && peerID != myPeerID { - candidates.append(peerID) - } - } - - // Randomly select up to maxPeers peers - return Array(candidates.shuffled().prefix(maxPeers)) - } - - private func sendViaSelectiveRelay(_ packet: BitchatPacket, recipientPeerID: String) { - // Select best relay candidates - let relayPeers = selectBestRelayPeers(excluding: recipientPeerID) - - if relayPeers.isEmpty { - // No relay candidates, fall back to broadcast - SecureLogger.log("No relay candidates for private message to \(recipientPeerID), using broadcast", - category: SecureLogger.session, level: .warning) - broadcastPacket(packet) - return - } - - // Limit TTL for relay - var relayPacket = packet - relayPacket.ttl = min(packet.ttl, 2) // Max 2 hops for private message relay - - guard let data = relayPacket.toBinaryData() else { return } - - // Send to selected relay peers - var sentCount = 0 - for relayPeerID in relayPeers { - if let peripheral = connectedPeripherals[relayPeerID], - let characteristic = peripheralCharacteristics[peripheral], - peripheral.state == .connected { - writeToPeripheral(data, peripheral: peripheral, characteristic: characteristic, peerID: relayPeerID) - sentCount += 1 - } - } - - SecureLogger.log("Sent private message to \(sentCount) relay peers (targeting \(recipientPeerID))", - category: SecureLogger.session, level: .info) - - // If no relays worked, fall back to broadcast - if sentCount == 0 { - SecureLogger.log("Failed to send to relay peers, falling back to broadcast", - category: SecureLogger.session, level: .warning) - broadcastPacket(packet) - } - } - - // MARK: - Connection Pool Management - - private func findLeastRecentlyUsedPeripheral() -> String? { - var lruPeripheralID: String? - var oldestActivityTime = Date() - - for (peripheralID, peripheral) in connectionPool { - // Only consider connected peripherals - guard peripheral.state == .connected else { continue } - - // Skip if this peripheral has an active peer connection - if let peerID = peerIDByPeripheralID[peripheralID], - self.peerSessions[peerID]?.isActivePeer == true { - continue - } - - // Find the least recently used peripheral based on last activity - if let lastActivity = lastActivityByPeripheralID[peripheralID], - lastActivity < oldestActivityTime { - oldestActivityTime = lastActivity - lruPeripheralID = peripheralID - } else if lastActivityByPeripheralID[peripheralID] == nil { - // If no activity recorded, it's a candidate for removal - lruPeripheralID = peripheralID - break - } - } - - return lruPeripheralID - } - - // Track activity for peripherals - private func updatePeripheralActivity(_ peripheralID: String) { - lastActivityByPeripheralID[peripheralID] = Date() - } -} diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift new file mode 100644 index 00000000..6b6ea7fc --- /dev/null +++ b/bitchat/Services/CommandProcessor.swift @@ -0,0 +1,267 @@ +// +// CommandProcessor.swift +// bitchat +// +// Handles command parsing and execution for BitChat +// This is free and unencumbered software released into the public domain. +// + +import Foundation + +/// Result of command processing +enum CommandResult { + case success(message: String?) + case error(message: String) + case handled // Command handled, no message needed +} + +/// Processes chat commands in a focused, efficient way +@MainActor +class CommandProcessor { + weak var chatViewModel: ChatViewModel? + weak var meshService: SimplifiedBluetoothService? + + init(chatViewModel: ChatViewModel? = nil, meshService: SimplifiedBluetoothService? = nil) { + self.chatViewModel = chatViewModel + self.meshService = meshService + } + + /// Process a command string + @MainActor + func process(_ command: String) -> CommandResult { + let parts = command.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false) + guard let cmd = parts.first else { return .error(message: "Invalid command") } + let args = parts.count > 1 ? String(parts[1]) : "" + + switch cmd { + case "/m", "/msg": + return handleMessage(args) + case "/w", "/who": + return handleWho() + case "/clear": + return handleClear() + case "/hug": + return handleEmote(args, action: "hugs", emoji: "🫂") + case "/slap": + return handleEmote(args, action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout") + case "/block": + return handleBlock(args) + case "/unblock": + return handleUnblock(args) + case "/fav": + return handleFavorite(args, add: true) + case "/unfav": + return handleFavorite(args, add: false) + case "/help", "/h": + return handleHelp() + default: + return .error(message: "unknown command: \(cmd)") + } + } + + // MARK: - Command Handlers + + private func handleMessage(_ args: String) -> CommandResult { + let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false) + guard !parts.isEmpty else { + return .error(message: "usage: /msg @nickname [message]") + } + + let targetName = String(parts[0]) + let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName + + guard let peerID = chatViewModel?.getPeerIDForNickname(nickname) else { + return .error(message: "'\(nickname)' not found") + } + + chatViewModel?.startPrivateChat(with: peerID) + + if parts.count > 1 { + let message = String(parts[1]) + chatViewModel?.sendPrivateMessage(message, to: peerID) + } + + return .success(message: "started private chat with \(nickname)") + } + + private func handleWho() -> CommandResult { + guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else { + return .success(message: "no one else is online right now") + } + + let onlineList = peers.values.sorted().joined(separator: ", ") + return .success(message: "online: \(onlineList)") + } + + private func handleClear() -> CommandResult { + if let peerID = chatViewModel?.selectedPrivateChatPeer { + chatViewModel?.privateChats[peerID]?.removeAll() + } else { + chatViewModel?.messages.removeAll() + } + return .handled + } + + private func handleEmote(_ args: String, action: String, emoji: String, suffix: String = "") -> CommandResult { + let targetName = args.trimmingCharacters(in: .whitespaces) + guard !targetName.isEmpty else { + return .error(message: "usage: /\(action) ") + } + + let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName + + guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname), + let myNickname = chatViewModel?.nickname else { + return .error(message: "cannot \(action) \(nickname): not found") + } + + let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *" + + if chatViewModel?.selectedPrivateChatPeer != nil { + // In private chat + if let peerNickname = meshService?.getPeerNicknames()[targetPeerID] { + let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *" + meshService?.sendPrivateMessage(personalMessage, to: targetPeerID, + recipientNickname: peerNickname, + messageID: UUID().uuidString) + } + } else { + // In public chat + meshService?.sendMessage(emoteContent) + } + + return .handled + } + + private func handleBlock(_ args: String) -> CommandResult { + let targetName = args.trimmingCharacters(in: .whitespaces) + + if targetName.isEmpty { + // List blocked users + guard let blockedUsers = chatViewModel?.blockedUsers, !blockedUsers.isEmpty else { + return .success(message: "no blocked peers") + } + + var blockedNicknames: [String] = [] + if let peers = meshService?.getPeerNicknames() { + for (peerID, nickname) in peers { + if let fingerprint = meshService?.getPeerFingerprint(peerID), + blockedUsers.contains(fingerprint) { + blockedNicknames.append(nickname) + } + } + } + + let list = blockedNicknames.isEmpty ? "blocked peers (not currently online)" + : blockedNicknames.sorted().joined(separator: ", ") + return .success(message: "blocked peers: \(list)") + } + + let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName + + guard let peerID = chatViewModel?.getPeerIDForNickname(nickname), + let fingerprint = meshService?.getPeerFingerprint(peerID) else { + return .error(message: "cannot block \(nickname): not found or unable to verify identity") + } + + if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { + return .success(message: "\(nickname) is already blocked") + } + + // Block the user + if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { + identity.isBlocked = true + identity.isFavorite = false + SecureIdentityStateManager.shared.updateSocialIdentity(identity) + } else { + let blockedIdentity = SocialIdentity( + fingerprint: fingerprint, + localPetname: nil, + claimedNickname: nickname, + trustLevel: .unknown, + isFavorite: false, + isBlocked: true, + notes: nil + ) + SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity) + } + + // The peerStateManager and SecureIdentityStateManager handle the blocking state + + return .success(message: "blocked \(nickname). you will no longer receive messages from them") + } + + private func handleUnblock(_ args: String) -> CommandResult { + let targetName = args.trimmingCharacters(in: .whitespaces) + guard !targetName.isEmpty else { + return .error(message: "usage: /unblock ") + } + + let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName + + guard let peerID = chatViewModel?.getPeerIDForNickname(nickname), + let fingerprint = meshService?.getPeerFingerprint(peerID) else { + return .error(message: "cannot unblock \(nickname): not found") + } + + if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { + return .success(message: "\(nickname) is not blocked") + } + + SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false) + // The SecureIdentityStateManager handles the unblocking state + + return .success(message: "unblocked \(nickname)") + } + + private func handleFavorite(_ args: String, add: Bool) -> CommandResult { + let targetName = args.trimmingCharacters(in: .whitespaces) + guard !targetName.isEmpty else { + return .error(message: "usage: /\(add ? "fav" : "unfav") ") + } + + let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName + + guard let peerID = chatViewModel?.getPeerIDForNickname(nickname), + let noisePublicKey = Data(hexString: peerID) else { + return .error(message: "can't find peer: \(nickname)") + } + + if add { + let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: noisePublicKey, + peerNostrPublicKey: existingFavorite?.peerNostrPublicKey, + peerNickname: nickname + ) + + chatViewModel?.toggleFavorite(peerID: peerID) + chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: true) + + return .success(message: "added \(nickname) to favorites") + } else { + FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) + + chatViewModel?.toggleFavorite(peerID: peerID) + chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: false) + + return .success(message: "removed \(nickname) from favorites") + } + } + + private func handleHelp() -> CommandResult { + let helpText = """ + commands: + /msg @name - start private chat + /who - list who's online + /clear - clear messages + /hug @name - send a hug + /slap @name - slap with a trout + /fav @name - add to favorites + /unfav @name - remove from favorites + /block @name - block + /unblock @name - unblock + """ + return .success(message: helpText) + } +} diff --git a/bitchat/Services/DeliveryTracker.swift b/bitchat/Services/DeliveryTracker.swift deleted file mode 100644 index 8cc8f1be..00000000 --- a/bitchat/Services/DeliveryTracker.swift +++ /dev/null @@ -1,294 +0,0 @@ -// -// DeliveryTracker.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Combine - -class DeliveryTracker { - static let shared = DeliveryTracker() - - // Track pending deliveries - private var pendingDeliveries: [String: PendingDelivery] = [:] - private let pendingLock = NSLock() - - // Track received ACKs to prevent duplicates - private var receivedAckIDs = Set() - private var sentAckIDs = Set() - - // Timeout configuration - private let privateMessageTimeout: TimeInterval = 120 // 2 minutes - private let roomMessageTimeout: TimeInterval = 180 // 3 minutes - private let favoriteTimeout: TimeInterval = 600 // 10 minutes for favorites - - // Retry configuration - private let maxRetries = 3 - private let retryDelay: TimeInterval = 5 // Base retry delay - - // Publishers for UI updates - let deliveryStatusUpdated = PassthroughSubject<(messageID: String, status: DeliveryStatus), Never>() - - // Cleanup timer - private var cleanupTimer: Timer? - - struct PendingDelivery { - let messageID: String - let sentAt: Date - let recipientID: String - let recipientNickname: String - let retryCount: Int - let isFavorite: Bool - var timeoutTimer: Timer? - - var isTimedOut: Bool { - let timeout: TimeInterval = isFavorite ? 300 : 30 - return Date().timeIntervalSince(sentAt) > timeout - } - - var shouldRetry: Bool { - return retryCount < 3 && isFavorite - } - } - - private init() { - startCleanupTimer() - } - - deinit { - cleanupTimer?.invalidate() - } - - // MARK: - Public Methods - - func trackMessage(_ message: BitchatMessage, recipientID: String, recipientNickname: String, isFavorite: Bool = false, expectedRecipients: Int = 1) { - // Only track private messages - guard message.isPrivate else { return } - - SecureLogger.log("Tracking message \(message.id) - private: \(message.isPrivate), recipient: \(recipientNickname)", category: SecureLogger.session, level: .info) - - - let delivery = PendingDelivery( - messageID: message.id, - sentAt: Date(), - recipientID: recipientID, - recipientNickname: recipientNickname, - retryCount: 0, - isFavorite: isFavorite, - timeoutTimer: nil - ) - - // Store the delivery with lock - pendingLock.lock() - pendingDeliveries[message.id] = delivery - pendingLock.unlock() - - // Update status to sent (only if not already delivered) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - guard let self = self else { return } - - self.pendingLock.lock() - let stillPending = self.pendingDeliveries[message.id] != nil - self.pendingLock.unlock() - - // Only update to sent if still pending (not already delivered) - if stillPending { - SecureLogger.log("Updating message \(message.id) to sent status (still pending)", category: SecureLogger.session, level: .debug) - self.updateDeliveryStatus(message.id, status: .sent) - } else { - SecureLogger.log("Skipping sent status update for \(message.id) - already delivered", category: SecureLogger.session, level: .debug) - } - } - - // Schedule timeout (outside of lock) - scheduleTimeout(for: message.id) - } - - func processDeliveryAck(_ ack: DeliveryAck) { - pendingLock.lock() - defer { pendingLock.unlock() } - - SecureLogger.log("Processing delivery ACK for message \(ack.originalMessageID) from \(ack.recipientNickname)", category: SecureLogger.session, level: .info) - - // Prevent duplicate ACK processing - guard !receivedAckIDs.contains(ack.ackID) else { - SecureLogger.log("Duplicate ACK \(ack.ackID) - ignoring", category: SecureLogger.session, level: .warning) - return - } - receivedAckIDs.insert(ack.ackID) - - // Find the pending delivery - guard let delivery = pendingDeliveries[ack.originalMessageID] else { - // Message might have already been delivered or timed out - SecureLogger.log("No pending delivery found for message \(ack.originalMessageID)", category: SecureLogger.session, level: .warning) - return - } - - // Cancel timeout timer - delivery.timeoutTimer?.invalidate() - - // Direct message - mark as delivered - SecureLogger.log("Marking private message \(ack.originalMessageID) as delivered to \(ack.recipientNickname)", category: SecureLogger.session, level: .info) - updateDeliveryStatus(ack.originalMessageID, status: .delivered(to: ack.recipientNickname, at: Date())) - pendingDeliveries.removeValue(forKey: ack.originalMessageID) - } - - func generateAck(for message: BitchatMessage, myPeerID: String, myNickname: String, hopCount: UInt8) -> DeliveryAck? { - // Don't ACK our own messages - guard message.senderPeerID != myPeerID else { - return nil - } - - // Only ACK private messages - guard message.isPrivate else { - return nil - } - - // Don't ACK if we've already sent an ACK for this message - guard !sentAckIDs.contains(message.id) else { - return nil - } - sentAckIDs.insert(message.id) - - - return DeliveryAck( - originalMessageID: message.id, - recipientID: myPeerID, - recipientNickname: myNickname, - hopCount: hopCount - ) - } - - func clearDeliveryStatus(for messageID: String) { - pendingLock.lock() - defer { pendingLock.unlock() } - - if let delivery = pendingDeliveries[messageID] { - delivery.timeoutTimer?.invalidate() - } - pendingDeliveries.removeValue(forKey: messageID) - } - - // MARK: - Private Methods - - private func updateDeliveryStatus(_ messageID: String, status: DeliveryStatus) { - SecureLogger.log("Updating delivery status for message \(messageID): \(status)", category: SecureLogger.session, level: .debug) - DispatchQueue.main.async { [weak self] in - self?.deliveryStatusUpdated.send((messageID: messageID, status: status)) - } - } - - private func scheduleTimeout(for messageID: String) { - // Get delivery info with lock - pendingLock.lock() - guard let delivery = pendingDeliveries[messageID] else { - pendingLock.unlock() - return - } - let isFavorite = delivery.isFavorite - pendingLock.unlock() - - let timeout = isFavorite ? favoriteTimeout : privateMessageTimeout - - let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in - self?.handleTimeout(messageID: messageID) - } - - pendingLock.lock() - if var updatedDelivery = pendingDeliveries[messageID] { - updatedDelivery.timeoutTimer = timer - pendingDeliveries[messageID] = updatedDelivery - } - pendingLock.unlock() - } - - private func handleTimeout(messageID: String) { - pendingLock.lock() - guard let delivery = pendingDeliveries[messageID] else { - pendingLock.unlock() - return - } - - let shouldRetry = delivery.shouldRetry - - if shouldRetry { - pendingLock.unlock() - // Retry for favorites (outside of lock) - retryDelivery(messageID: messageID) - } else { - // Mark as failed - let reason = "Message not delivered" - pendingDeliveries.removeValue(forKey: messageID) - pendingLock.unlock() - updateDeliveryStatus(messageID, status: .failed(reason: reason)) - } - } - - private func retryDelivery(messageID: String) { - pendingLock.lock() - guard let delivery = pendingDeliveries[messageID] else { - pendingLock.unlock() - return - } - - // Increment retry count - let newDelivery = PendingDelivery( - messageID: delivery.messageID, - sentAt: delivery.sentAt, - recipientID: delivery.recipientID, - recipientNickname: delivery.recipientNickname, - retryCount: delivery.retryCount + 1, - isFavorite: delivery.isFavorite, - timeoutTimer: nil - ) - - pendingDeliveries[messageID] = newDelivery - let retryCount = delivery.retryCount - pendingLock.unlock() - - // Exponential backoff for retry - let delay = retryDelay * pow(2, Double(retryCount)) - - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - // Trigger resend through delegate or notification - NotificationCenter.default.post( - name: Notification.Name("bitchat.retryMessage"), - object: nil, - userInfo: ["messageID": messageID] - ) - - // Schedule new timeout - self?.scheduleTimeout(for: messageID) - } - } - - private func startCleanupTimer() { - cleanupTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in - self?.cleanupOldDeliveries() - } - } - - private func cleanupOldDeliveries() { - pendingLock.lock() - defer { pendingLock.unlock() } - - let now = Date() - let maxAge: TimeInterval = 3600 // 1 hour - - // Clean up old pending deliveries - pendingDeliveries = pendingDeliveries.filter { (_, delivery) in - now.timeIntervalSince(delivery.sentAt) < maxAge - } - - // Clean up old ACK IDs (keep last 1000) - if receivedAckIDs.count > 1000 { - receivedAckIDs.removeAll() - } - if sentAckIDs.count > 1000 { - sentAckIDs.removeAll() - } - } -} \ No newline at end of file diff --git a/bitchat/Services/MessageRetryService.swift b/bitchat/Services/MessageRetryService.swift deleted file mode 100644 index aee29b74..00000000 --- a/bitchat/Services/MessageRetryService.swift +++ /dev/null @@ -1,212 +0,0 @@ -// -// MessageRetryService.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Combine -import CryptoKit - -struct RetryableMessage { - let id: String - let originalMessageID: String? - let originalTimestamp: Date? - let content: String - let mentions: [String]? - let isPrivate: Bool - let recipientPeerID: String? - let recipientNickname: String? - let retryCount: Int - let maxRetries: Int = 3 - let nextRetryTime: Date -} - -class MessageRetryService { - static let shared = MessageRetryService() - - private var retryQueue: [RetryableMessage] = [] - private var retryTimer: Timer? - private let retryInterval: TimeInterval = 2.0 // Retry every 2 seconds for faster sync - private let maxQueueSize = 50 - - weak var meshService: BluetoothMeshService? - - private init() { - startRetryTimer() - } - - deinit { - retryTimer?.invalidate() - } - - private func startRetryTimer() { - retryTimer = Timer.scheduledTimer(withTimeInterval: retryInterval, repeats: true) { [weak self] _ in - self?.processRetryQueue() - } - } - - func addMessageForRetry( - content: String, - mentions: [String]? = nil, - isPrivate: Bool = false, - recipientPeerID: String? = nil, - recipientNickname: String? = nil, - originalMessageID: String? = nil, - originalTimestamp: Date? = nil - ) { - // Don't queue empty or whitespace-only messages - guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return - } - - // Don't queue if we're at capacity - guard retryQueue.count < maxQueueSize else { - return - } - - // Check if this message is already in the queue - if let messageID = originalMessageID { - let alreadyQueued = retryQueue.contains { msg in - msg.originalMessageID == messageID - } - if alreadyQueued { - return // Don't add duplicate - } - } - - let retryMessage = RetryableMessage( - id: UUID().uuidString, - originalMessageID: originalMessageID, - originalTimestamp: originalTimestamp, - content: content, - mentions: mentions, - isPrivate: isPrivate, - recipientPeerID: recipientPeerID, - recipientNickname: recipientNickname, - retryCount: 0, - nextRetryTime: Date().addingTimeInterval(retryInterval) - ) - - retryQueue.append(retryMessage) - - // Sort the queue by original timestamp to maintain message order - retryQueue.sort { (msg1, msg2) in - let time1 = msg1.originalTimestamp ?? Date.distantPast - let time2 = msg2.originalTimestamp ?? Date.distantPast - return time1 < time2 - } - } - - private func processRetryQueue() { - guard meshService != nil else { return } - - let now = Date() - var messagesToRetry: [RetryableMessage] = [] - var updatedQueue: [RetryableMessage] = [] - - for message in retryQueue { - if message.nextRetryTime <= now { - messagesToRetry.append(message) - } else { - updatedQueue.append(message) - } - } - - retryQueue = updatedQueue - - // Sort messages by original timestamp to maintain order - messagesToRetry.sort { (msg1, msg2) in - let time1 = msg1.originalTimestamp ?? Date.distantPast - let time2 = msg2.originalTimestamp ?? Date.distantPast - return time1 < time2 - } - - // Send messages with delay to maintain order - for (index, message) in messagesToRetry.enumerated() { - // Check if we should still retry - if message.retryCount >= message.maxRetries { - continue - } - - // Add delay between messages to ensure proper ordering - let delay = Double(index) * 0.05 // 50ms between messages - - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self = self, - let meshService = self.meshService else { return } - - // Check connectivity before retrying - let viewModel = meshService.delegate as? ChatViewModel - let connectedPeers = viewModel?.connectedPeers ?? [] - - if message.isPrivate { - // For private messages, check if recipient is connected - if let recipientID = message.recipientPeerID, - connectedPeers.contains(recipientID) { - // Retry private message - meshService.sendPrivateMessage( - message.content, - to: recipientID, - recipientNickname: message.recipientNickname ?? "unknown", - messageID: message.originalMessageID - ) - } else { - // Recipient not connected, keep in queue with updated retry time - var updatedMessage = message - updatedMessage = RetryableMessage( - id: message.id, - originalMessageID: message.originalMessageID, - originalTimestamp: message.originalTimestamp, - content: message.content, - mentions: message.mentions, - isPrivate: message.isPrivate, - recipientPeerID: message.recipientPeerID, - recipientNickname: message.recipientNickname, - retryCount: message.retryCount + 1, - nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2)) - ) - self.retryQueue.append(updatedMessage) - } - } else { - // Regular message - if !connectedPeers.isEmpty { - meshService.sendMessage( - message.content, - mentions: message.mentions ?? [], - to: nil, - messageID: message.originalMessageID, - timestamp: message.originalTimestamp - ) - } else { - // No peers connected, keep in queue - var updatedMessage = message - updatedMessage = RetryableMessage( - id: message.id, - originalMessageID: message.originalMessageID, - originalTimestamp: message.originalTimestamp, - content: message.content, - mentions: message.mentions, - isPrivate: message.isPrivate, - recipientPeerID: message.recipientPeerID, - recipientNickname: message.recipientNickname, - retryCount: message.retryCount + 1, - nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2)) - ) - self.retryQueue.append(updatedMessage) - } - } - } - } - } - - func clearRetryQueue() { - retryQueue.removeAll() - } - - func getRetryQueueCount() -> Int { - return retryQueue.count - } -} diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift deleted file mode 100644 index e7ff699d..00000000 --- a/bitchat/Services/MessageRouter.swift +++ /dev/null @@ -1,655 +0,0 @@ -import Foundation -import Combine - -/// Routes messages through the appropriate transport (Bluetooth mesh or Nostr) -@MainActor -class MessageRouter: ObservableObject { - - enum Transport { - case bluetoothMesh - case nostr - } - - enum DeliveryStatus { - case pending - case sent - case delivered - case failed(Error) - } - - struct RoutedMessage { - let id: String - let content: String - let recipientNoisePublicKey: Data - let transport: Transport - let timestamp: Date - var status: DeliveryStatus - } - - @Published private(set) var pendingMessages: [String: RoutedMessage] = [:] - - private let meshService: BluetoothMeshService - private let nostrRelay: NostrRelayManager - private let favoritesService: FavoritesPersistenceService - private let processedMessagesService = ProcessedMessagesService.shared - - private var cancellables = Set() - private let messageDeduplication = LRUCache(maxSize: 1000) - - init( - meshService: BluetoothMeshService, - nostrRelay: NostrRelayManager - ) { - self.meshService = meshService - self.nostrRelay = nostrRelay - self.favoritesService = FavoritesPersistenceService.shared - - setupBindings() - } - - /// Send a message to a peer, automatically selecting the best transport - func sendMessage( - _ content: String, - to recipientNoisePublicKey: Data, - preferredTransport: Transport? = nil, - messageId: String? = nil - ) async throws { - - let finalMessageId = messageId ?? UUID().uuidString - - // Check if peer is available on mesh (actually connected, not just known) - let recipientHexID = recipientNoisePublicKey.hexEncodedString() - let peerAvailableOnMesh = meshService.isPeerConnected(recipientHexID) - - // Check if this is a mutual favorite - let isMutualFavorite = favoritesService.isMutualFavorite(recipientNoisePublicKey) - - // Determine transport - let transport: Transport - if let preferred = preferredTransport { - transport = preferred - } else if peerAvailableOnMesh { - // Always prefer mesh when available - transport = .bluetoothMesh - } else if isMutualFavorite { - // Use Nostr for mutual favorites when not on mesh - transport = .nostr - } else { - throw MessageRouterError.peerNotReachable - } - - // Create routed message - let routedMessage = RoutedMessage( - id: finalMessageId, - content: content, - recipientNoisePublicKey: recipientNoisePublicKey, - transport: transport, - timestamp: Date(), - status: .pending - ) - - pendingMessages[finalMessageId] = routedMessage - - // Route based on transport - switch transport { - case .bluetoothMesh: - try await sendViaMesh(routedMessage) - - case .nostr: - try await sendViaNostr(routedMessage) - } - } - - /// Send a favorite/unfavorite notification - func sendFavoriteNotification( - to recipientNoisePublicKey: Data, - isFavorite: Bool - ) async throws { - - // messageType is used for logging below - // let messageType: MessageType = isFavorite ? .favorited : .unfavorited - let recipientHexID = recipientNoisePublicKey.hexEncodedString() - let action = isFavorite ? "favorite" : "unfavorite" - - - // Try mesh first - if meshService.getPeerNicknames()[recipientHexID] != nil { - - // Send via mesh as a system message - meshService.sendFavoriteNotification(to: recipientHexID, isFavorite: isFavorite) - - } else if let favoriteStatus = favoritesService.getFavoriteStatus(for: recipientNoisePublicKey), - let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey { - - - // Send via Nostr as a special message - guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { - throw MessageRouterError.noNostrIdentity - } - - // Include our npub in the content - let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)" - let event = try NostrProtocol.createPrivateMessage( - content: content, - recipientPubkey: recipientNostrPubkey, - senderIdentity: senderIdentity - ) - - nostrRelay.sendEvent(event) - } else { - SecureLogger.log("⚠️ Cannot send \(action) notification - peer not reachable via mesh or Nostr", - category: SecureLogger.session, level: .warning) - } - } - - // MARK: - Private Methods - - private func sendViaMesh(_ message: RoutedMessage) async throws { - // Send the message through mesh - using sendPrivateMessage for now - let recipientHexID = message.recipientNoisePublicKey.hexEncodedString() - if let recipientNickname = meshService.getPeerNicknames()[recipientHexID] { - meshService.sendPrivateMessage(message.content, to: recipientHexID, recipientNickname: recipientNickname, messageID: message.id) - } - - // Update status - pendingMessages[message.id]?.status = .sent - } - - private func sendViaNostr(_ message: RoutedMessage) async throws { - // Get recipient's Nostr public key - let favoriteStatus = favoritesService.getFavoriteStatus(for: message.recipientNoisePublicKey) - - // Looking up Nostr key for recipient - - if favoriteStatus != nil { - // Found favorite relationship - } else { - SecureLogger.log("❌ No favorite relationship found", - category: SecureLogger.session, level: .error) - } - - guard let favoriteStatus = favoriteStatus, - let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey else { - throw MessageRouterError.noNostrPublicKey - } - - // Get sender's Nostr identity - guard let senderIdentity = try NostrIdentityBridge.getCurrentNostrIdentity() else { - throw MessageRouterError.noNostrIdentity - } - - // Create NIP-17 encrypted message with structured content - let structuredContent = "MSG:\(message.id):\(message.content)" - let event = try NostrProtocol.createPrivateMessage( - content: structuredContent, - recipientPubkey: recipientNostrPubkey, - senderIdentity: senderIdentity - ) - - // Created gift wrap event - - // Send via relay - nostrRelay.sendEvent(event) - - // Update status - pendingMessages[message.id]?.status = .sent - } - - private func setupBindings() { - // Monitor Nostr messages - setupNostrMessageHandling() - - // Clean up old pending messages periodically - Timer.publish(every: 60, on: .main, in: .common) - .autoconnect() - .sink { [weak self] _ in - self?.cleanupOldMessages() - } - .store(in: &cancellables) - - // Listen for app becoming active to check for messages - NotificationCenter.default.publisher(for: .appDidBecomeActive) - .sink { [weak self] _ in - self?.checkForNostrMessages() - } - .store(in: &cancellables) - } - - private func setupNostrMessageHandling() { - guard (try? NostrIdentityBridge.getCurrentNostrIdentity()) != nil else { - SecureLogger.log("⚠️ No Nostr identity available for initial setup", category: SecureLogger.session, level: .warning) - return - } - - - // Connect to relays if not already connected - if !nostrRelay.isConnected { - nostrRelay.connect() - - // Wait for connections to establish before subscribing - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - self?.subscribeToNostrMessages() - } - } else { - // Already connected, subscribe immediately - subscribeToNostrMessages() - } - } - - /// Check for Nostr messages when app becomes active - func checkForNostrMessages() { - // Checking for Nostr messages - - guard (try? NostrIdentityBridge.getCurrentNostrIdentity()) != nil else { - SecureLogger.log("⚠️ No Nostr identity available for message check", category: SecureLogger.session, level: .warning) - return - } - - // Ensure we're connected to relays first - if !nostrRelay.isConnected { - // Connecting to Nostr relays - nostrRelay.connect() - - // Wait a bit for connections to establish before subscribing - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.subscribeToNostrMessages() - } - } else { - // Already connected, subscribe immediately - subscribeToNostrMessages() - } - } - - private func subscribeToNostrMessages() { - guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } - - // Subscribing to Nostr messages - // Full pubkey recorded - // Pubkey length verified - - // Unsubscribe existing subscription to refresh - nostrRelay.unsubscribe(id: "router-messages") - - // Create a new subscription for recent messages - let sinceDate = processedMessagesService.getSubscriptionSinceDate() - let filter = NostrFilter.giftWrapsFor( - pubkey: currentIdentity.publicKeyHex, - since: sinceDate - ) - - // Subscribing to messages since date - - // Subscribing to gift wraps - - nostrRelay.subscribe(filter: filter, id: "router-messages") { [weak self] event in - // Received Nostr event - self?.handleNostrMessage(event) - } - } - - private func handleNostrMessage(_ giftWrap: NostrEvent) { - // Check if we've already processed this event - if processedMessagesService.isMessageProcessed(giftWrap.id) { - // Skipping already processed event - return - } - - // Attempting to decrypt gift wrap - // Full event ID recorded - // Event timestamp recorded - // Event tags recorded - - // Decrypt the message - guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { - SecureLogger.log("❌ No current Nostr identity available", - category: SecureLogger.session, level: .error) - return - } - - // Check if this event is actually tagged for us - let ourPubkey = currentIdentity.publicKeyHex - let isTaggedForUs = giftWrap.tags.contains { tag in - tag.count >= 2 && tag[0] == "p" && tag[1] == ourPubkey - } - - if !isTaggedForUs { - SecureLogger.log("⚠️ Gift wrap not tagged for us! Our pubkey: \(ourPubkey.prefix(8))..., Event tags: \(giftWrap.tags)", - category: SecureLogger.session, level: .warning) - return - } - - do { - let (content, senderPubkey) = try NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: currentIdentity - ) - - - // Mark this event as processed to avoid duplicates on app restart - let eventTimestamp = Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at)) - processedMessagesService.markMessageAsProcessed(giftWrap.id, timestamp: eventTimestamp) - - // Check for deduplication within current session - let messageHash = "\(senderPubkey)-\(content)-\(giftWrap.created_at)" - if messageDeduplication.get(messageHash) != nil { - return // Already processed in this session - } - messageDeduplication.set(messageHash, value: Date()) - - // Handle special messages - if content.hasPrefix("FAVORITED") || content.hasPrefix("UNFAVORITED") { - let parts = content.split(separator: ":") - let isFavorite = parts.first == "FAVORITED" - let nostrNpub = parts.count > 1 ? String(parts[1]) : nil - handleFavoriteNotification(from: senderPubkey, isFavorite: isFavorite, nostrNpub: nostrNpub) - return - } - - // Handle delivery acknowledgments - if content.hasPrefix("DELIVERED:") { - let parts = content.split(separator: ":") - if parts.count > 1 { - let messageId = String(parts[1]) - handleDeliveryAcknowledgment(messageId: messageId, from: senderPubkey) - } - return - } - - // Handle read receipts - if content.hasPrefix("READ:") { - let parts = content.split(separator: ":", maxSplits: 1) - if parts.count > 1 { - let receiptDataString = String(parts[1]) - if let receiptData = Data(base64Encoded: receiptDataString), - let receipt = ReadReceipt.fromBinaryData(receiptData) { - handleReadReceipt(receipt, from: senderPubkey) - } - } - return - } - - // Find the sender's Noise public key - guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return } - - // Parse structured message content - var messageId = UUID().uuidString - var messageContent = content - - if content.hasPrefix("MSG:") { - let parts = content.split(separator: ":", maxSplits: 2) - if parts.count >= 3 { - messageId = String(parts[1]) - messageContent = String(parts[2]) - } - } - - // Create a BitchatMessage and inject into the stream - let chatMessage = BitchatMessage( - id: messageId, - sender: favoritesService.getFavoriteStatus(for: senderNoiseKey)?.peerNickname ?? "Unknown", - content: messageContent, - timestamp: Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at)), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: nil, - senderPeerID: senderNoiseKey.hexEncodedString(), - mentions: nil, - deliveryStatus: .delivered(to: "nostr", at: Date()) - ) - - // Post notification for ChatViewModel to handle - NotificationCenter.default.post( - name: .nostrMessageReceived, - object: nil, - userInfo: ["message": chatMessage] - ) - - // Send delivery acknowledgment back to sender - sendDeliveryAcknowledgment(for: chatMessage.id, to: senderPubkey) - - } catch { - SecureLogger.log("❌ Failed to decrypt gift wrap: \(error)", - category: SecureLogger.session, level: .error) - } - } - - private func handleFavoriteNotification(from nostrPubkey: String, isFavorite: Bool, nostrNpub: String? = nil) { - // Find the sender's Noise public key - guard let senderNoiseKey = findNoisePublicKey(for: nostrPubkey) else { return } - - // Update favorites service - nostrPubkey is already the hex public key - favoritesService.updatePeerFavoritedUs( - peerNoisePublicKey: senderNoiseKey, - favorited: isFavorite, - peerNostrPublicKey: nostrPubkey - ) - - // Post notification for UI update - NotificationCenter.default.post( - name: .favoriteStatusChanged, - object: nil, - userInfo: [ - "peerPublicKey": senderNoiseKey, - "isFavorite": isFavorite - ] - ) - } - - private func findNoisePublicKey(for nostrPubkey: String) -> Data? { - // Search through favorites for matching Nostr pubkey - for (noiseKey, relationship) in favoritesService.favorites { - if relationship.peerNostrPublicKey == nostrPubkey { - return noiseKey - } - } - return nil - } - - private func handleDeliveryAcknowledgment(messageId: String, from senderPubkey: String) { - - // Find the sender's Noise public key - guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return } - - // Post notification for ChatViewModel to update delivery status - NotificationCenter.default.post( - name: .messageDeliveryAcknowledged, - object: nil, - userInfo: [ - "messageId": messageId, - "senderNoiseKey": senderNoiseKey - ] - ) - } - - private func handleReadReceipt(_ receipt: ReadReceipt, from senderPubkey: String) { - - // Find the sender's Noise public key - guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return } - let senderHexID = senderNoiseKey.hexEncodedString() - - // Update the receipt with the correct sender ID - var updatedReceipt = receipt - updatedReceipt.readerID = senderHexID - - // Post notification for ChatViewModel to process - NotificationCenter.default.post( - name: .readReceiptReceived, - object: nil, - userInfo: ["receipt": updatedReceipt] - ) - } - - func sendReadReceipt( - for originalMessageID: String, - to recipientNoisePublicKey: Data, - preferredTransport: Transport? = nil - ) async throws { - - // Get nickname from delegate or use default - let nickname = (meshService.delegate as? ChatViewModel)?.nickname ?? "Anonymous" - - // Create read receipt - let receipt = ReadReceipt( - originalMessageID: originalMessageID, - readerID: meshService.myPeerID, - readerNickname: nickname - ) - - // Encode receipt - let receiptData = receipt.toBinaryData() - let content = "READ:\(receiptData.base64EncodedString())" - - // Check if peer is connected via mesh (mesh takes precedence) - let recipientHexID = recipientNoisePublicKey.hexEncodedString() - - // First check if the peer is currently connected with the given ID - var actualRecipientHexID = recipientHexID - var actualRecipientNoiseKey = recipientNoisePublicKey - - // Always check if they reconnected with a new ID, even if preferredTransport is specified - if let favoriteStatus = favoritesService.getFavoriteStatus(for: recipientNoisePublicKey) { - let peerNickname = favoriteStatus.peerNickname - - // Search through all current peers to find one with the same nickname - for (currentPeerID, currentNickname) in meshService.getPeerNicknames() { - if currentNickname == peerNickname, - currentPeerID != recipientHexID, - let currentNoiseKey = Data(hexString: currentPeerID) { - SecureLogger.log("🔄 Found updated peer ID for \(peerNickname): \(recipientHexID) -> \(currentPeerID)", - category: SecureLogger.session, level: .info) - actualRecipientHexID = currentPeerID - actualRecipientNoiseKey = currentNoiseKey - break - } - } - - // If still not found in connected peers, check all favorites for the current key - if meshService.getPeerNicknames()[actualRecipientHexID] == nil { - // Search through all favorites to find the current noise key for this nickname - for (noiseKey, relationship) in favoritesService.favorites { - if relationship.peerNickname == peerNickname && relationship.peerNostrPublicKey != nil { - SecureLogger.log("🔄 Using current favorite key for \(peerNickname): \(recipientHexID) -> \(noiseKey.hexEncodedString())", - category: SecureLogger.session, level: .info) - actualRecipientHexID = noiseKey.hexEncodedString() - actualRecipientNoiseKey = noiseKey - break - } - } - } - } - - let isConnectedOnMesh = meshService.isPeerConnected(actualRecipientHexID) - - if isConnectedOnMesh && preferredTransport != .nostr { - // Send via mesh - SecureLogger.log("📡 Sending read receipt via mesh to \(actualRecipientHexID)", - category: SecureLogger.session, level: .debug) - meshService.sendReadReceipt(receipt, to: actualRecipientHexID) - } else { - // Send via Nostr - SecureLogger.log("🌐 Sending read receipt via Nostr to \(actualRecipientHexID)", - category: SecureLogger.session, level: .debug) - - // Get recipient's Nostr public key using the actual current noise key - let favoriteStatus = favoritesService.getFavoriteStatus(for: actualRecipientNoiseKey) - guard let recipientNostrPubkey = favoriteStatus?.peerNostrPublicKey else { - SecureLogger.log("❌ Cannot send read receipt - no Nostr key for recipient", - category: SecureLogger.session, level: .error) - throw MessageRouterError.noNostrKey - } - - guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { - SecureLogger.log("⚠️ No Nostr identity available for read receipt", - category: SecureLogger.session, level: .warning) - throw MessageRouterError.noIdentity - } - - // Create read receipt message - guard let event = try? NostrProtocol.createPrivateMessage( - content: content, - recipientPubkey: recipientNostrPubkey, - senderIdentity: senderIdentity - ) else { - SecureLogger.log("❌ Failed to create read receipt", - category: SecureLogger.session, level: .error) - throw MessageRouterError.encryptionFailed - } - - // Send via relay - nostrRelay.sendEvent(event) - } - } - - private func sendDeliveryAcknowledgment(for messageId: String, to recipientNostrPubkey: String) { - SecureLogger.log("📤 Sending delivery acknowledgment for message \(messageId)", - category: SecureLogger.session, level: .debug) - - guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { - SecureLogger.log("⚠️ No Nostr identity available for acknowledgment", - category: SecureLogger.session, level: .warning) - return - } - - // Create acknowledgment message - let content = "DELIVERED:\(messageId)" - guard let event = try? NostrProtocol.createPrivateMessage( - content: content, - recipientPubkey: recipientNostrPubkey, - senderIdentity: senderIdentity - ) else { - SecureLogger.log("❌ Failed to create delivery acknowledgment", - category: SecureLogger.session, level: .error) - return - } - - // Send via relay - nostrRelay.sendEvent(event) - } - - private func cleanupOldMessages() { - let cutoff = Date().addingTimeInterval(-300) // 5 minutes - pendingMessages = pendingMessages.filter { $0.value.timestamp > cutoff } - } -} - -// MARK: - Errors - -enum MessageRouterError: LocalizedError { - case peerNotReachable - case noNostrPublicKey - case noNostrIdentity - case transportFailed - case noNostrKey - case noIdentity - case encryptionFailed - - var errorDescription: String? { - switch self { - case .peerNotReachable: - return "Peer is not reachable via mesh or Nostr" - case .noNostrPublicKey: - return "Peer's Nostr public key is unknown" - case .noNostrIdentity: - return "No Nostr identity available" - case .transportFailed: - return "Failed to send message" - case .noNostrKey: - return "No Nostr key available for recipient" - case .noIdentity: - return "No identity available" - case .encryptionFailed: - return "Failed to encrypt message" - } - } -} - -// MARK: - Notification Names - -extension Notification.Name { - static let nostrMessageReceived = Notification.Name("NostrMessageReceived") - static let messageDeliveryAcknowledged = Notification.Name("MessageDeliveryAcknowledged") - static let readReceiptReceived = Notification.Name("ReadReceiptReceived") - static let appDidBecomeActive = Notification.Name("AppDidBecomeActive") -} - diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 5cd6ae3b..d1f0853e 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -11,7 +11,7 @@ /// /// High-level encryption service that manages Noise Protocol sessions for secure /// peer-to-peer communication in BitChat. Acts as the bridge between the transport -/// layer (BluetoothMeshService) and the cryptographic layer (NoiseProtocol). +/// layer (SimplifiedBluetoothService) and the cryptographic layer (NoiseProtocol). /// /// ## Overview /// This service provides a simplified API for establishing and managing encrypted @@ -60,7 +60,7 @@ /// ``` /// /// ## Integration Points -/// - **BluetoothMeshService**: Calls this service for all private messages +/// - **SimplifiedBluetoothService**: Calls this service for all private messages /// - **ChatViewModel**: Monitors encryption status for UI indicators /// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions /// - **KeychainManager**: Secure storage for identity keys @@ -162,9 +162,26 @@ class NoiseEncryptionService { private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute // Callbacks - var onPeerAuthenticated: ((String, String) -> Void)? // peerID, fingerprint + private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake + // Add a handler for peer authentication + func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) { + serviceQueue.async(flags: .barrier) { [weak self] in + self?.onPeerAuthenticatedHandlers.append(handler) + } + } + + // Legacy support - setting this will add to the handlers array + var onPeerAuthenticated: ((String, String) -> Void)? { + get { nil } // Always return nil for backward compatibility + set { + if let handler = newValue { + addOnPeerAuthenticatedHandler(handler) + } + } + } + init() { // Load or create static identity key (ONLY from keychain) let loadedKey: Curve25519.KeyAgreement.PrivateKey @@ -434,8 +451,12 @@ class NoiseEncryptionService { // Log security event SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) - // Notify about authentication - onPeerAuthenticated?(peerID, fingerprint) + // Notify all handlers about authentication + serviceQueue.async { [weak self] in + self?.onPeerAuthenticatedHandlers.forEach { handler in + handler(peerID, fingerprint) + } + } } private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String { diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift new file mode 100644 index 00000000..a24bd3b1 --- /dev/null +++ b/bitchat/Services/PrivateChatManager.swift @@ -0,0 +1,189 @@ +// +// PrivateChatManager.swift +// bitchat +// +// Manages private chat sessions and messages +// This is free and unencumbered software released into the public domain. +// + +import Foundation +import SwiftUI + +/// Manages all private chat functionality +class PrivateChatManager: ObservableObject { + @Published var privateChats: [String: [BitchatMessage]] = [:] + @Published var selectedPeer: String? = nil + @Published var unreadMessages: Set = [] + + private var selectedPeerFingerprint: String? = nil + var sentReadReceipts: Set = [] // Made accessible for ChatViewModel + + weak var meshService: SimplifiedBluetoothService? + + init(meshService: SimplifiedBluetoothService? = nil) { + self.meshService = meshService + } + + /// Start a private chat with a peer + func startChat(with peerID: String) { + selectedPeer = peerID + + // Store fingerprint for persistence across reconnections + if let fingerprint = meshService?.getPeerFingerprint(peerID) { + selectedPeerFingerprint = fingerprint + } + + // Mark messages as read + markAsRead(from: peerID) + + // Initialize chat if needed + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } + } + + /// End the current private chat + func endChat() { + selectedPeer = nil + selectedPeerFingerprint = nil + } + + /// Send a private message + func sendMessage(_ content: String, to peerID: String) { + guard let meshService = meshService, + let peerNickname = meshService.getPeerNicknames()[peerID] else { + return + } + + let messageID = UUID().uuidString + + // Create local message + let message = BitchatMessage( + id: messageID, + sender: meshService.myNickname, + content: content, + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: peerNickname, + senderPeerID: meshService.myPeerID, + mentions: nil, + deliveryStatus: .sending + ) + + // Add to chat + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } + privateChats[peerID]?.append(message) + + // Send via mesh service + meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID) + } + + /// Handle incoming private message + func handleIncomingMessage(_ message: BitchatMessage) { + guard let senderPeerID = message.senderPeerID else { return } + + // Initialize chat if needed + if privateChats[senderPeerID] == nil { + privateChats[senderPeerID] = [] + } + + // Add message + privateChats[senderPeerID]?.append(message) + + // Mark as unread if not in this chat + if selectedPeer != senderPeerID { + unreadMessages.insert(senderPeerID) + + // Send notification + NotificationService.shared.sendPrivateMessageNotification( + from: message.sender, + message: message.content, + peerID: senderPeerID + ) + } else { + // Send read receipt if viewing this chat + sendReadReceipt(for: message) + } + } + + /// Mark messages from a peer as read + func markAsRead(from peerID: String) { + unreadMessages.remove(peerID) + + // Send read receipts for unread messages that haven't been sent yet + if let messages = privateChats[peerID] { + for message in messages { + if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) { + sendReadReceipt(for: message) + } + } + } + } + + /// Update the selected peer if fingerprint matches (for reconnections) + func updateSelectedPeer(peers: [String: String]) { + guard let fingerprint = selectedPeerFingerprint else { return } + + // Find peer with matching fingerprint + for (peerID, _) in peers { + if meshService?.getPeerFingerprint(peerID) == fingerprint { + selectedPeer = peerID + break + } + } + } + + /// Get chat messages for current context + func getCurrentMessages() -> [BitchatMessage] { + guard let peer = selectedPeer else { return [] } + return privateChats[peer] ?? [] + } + + /// Clear a private chat + func clearChat(with peerID: String) { + privateChats[peerID]?.removeAll() + } + + /// Handle delivery acknowledgment + func handleDeliveryAck(messageID: String, from peerID: String) { + guard privateChats[peerID] != nil else { return } + + if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date()) + } + } + + /// Handle read receipt + func handleReadReceipt(messageID: String, from peerID: String) { + guard privateChats[peerID] != nil else { return } + + if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[index].deliveryStatus = .read(by: "recipient", at: Date()) + } + } + + // MARK: - Private Methods + + private func sendReadReceipt(for message: BitchatMessage) { + guard !sentReadReceipts.contains(message.id), + let senderPeerID = message.senderPeerID else { + return + } + + sentReadReceipts.insert(message.id) + + // Create read receipt using the simplified method + let receipt = ReadReceipt( + originalMessageID: message.id, + readerID: meshService?.myPeerID ?? "", + readerNickname: meshService?.myNickname ?? "" + ) + + // Send through mesh service's read receipt method + meshService?.sendReadReceipt(receipt, to: senderPeerID) + } +} \ No newline at end of file diff --git a/bitchat/Services/ProcessedMessagesService.swift b/bitchat/Services/ProcessedMessagesService.swift deleted file mode 100644 index 79458619..00000000 --- a/bitchat/Services/ProcessedMessagesService.swift +++ /dev/null @@ -1,106 +0,0 @@ -import Foundation - -/// Service to track processed messages across app restarts to prevent duplicates -@MainActor -final class ProcessedMessagesService { - static let shared = ProcessedMessagesService() - - private let userDefaults = UserDefaults.standard - private let processedMessagesKey = "ProcessedNostrMessages" - private let lastProcessedTimestampKey = "LastProcessedNostrTimestamp" - private let maxStoredMessages = 1000 // Keep last 1000 message IDs - - private var processedMessageIDs: Set = [] - private var lastProcessedTimestamp: Date? - - private init() { - loadProcessedMessages() - } - - /// Check if a message has already been processed - func isMessageProcessed(_ messageID: String) -> Bool { - return processedMessageIDs.contains(messageID) - } - - /// Mark a message as processed - func markMessageAsProcessed(_ messageID: String, timestamp: Date) { - processedMessageIDs.insert(messageID) - - // Update last processed timestamp if this message is newer - if let lastTimestamp = lastProcessedTimestamp { - if timestamp > lastTimestamp { - lastProcessedTimestamp = timestamp - } - } else { - lastProcessedTimestamp = timestamp - } - - // Trim if we have too many stored IDs - if processedMessageIDs.count > maxStoredMessages { - trimOldestMessages() - } - - saveProcessedMessages() - } - - /// Get the timestamp to use for Nostr subscription filters - func getSubscriptionSinceDate() -> Date { - // If we have a last processed timestamp, use it minus a small buffer - if let lastTimestamp = lastProcessedTimestamp { - // Go back 1 hour before last processed message for safety - return lastTimestamp.addingTimeInterval(-3600) - } - - // Default: look back 24 hours on first run - return Date().addingTimeInterval(-86400) - } - - /// Clear all processed messages (useful for debugging) - func clearProcessedMessages() { - processedMessageIDs.removeAll() - lastProcessedTimestamp = nil - saveProcessedMessages() - } - - // MARK: - Private Methods - - private func loadProcessedMessages() { - if let data = userDefaults.data(forKey: processedMessagesKey), - let decoded = try? JSONDecoder().decode([String].self, from: data) { - processedMessageIDs = Set(decoded) - } - - if let timestampInterval = userDefaults.object(forKey: lastProcessedTimestampKey) as? TimeInterval { - lastProcessedTimestamp = Date(timeIntervalSince1970: timestampInterval) - } - - } - - private func saveProcessedMessages() { - // Convert Set to Array for encoding - let messageArray = Array(processedMessageIDs) - if let encoded = try? JSONEncoder().encode(messageArray) { - userDefaults.set(encoded, forKey: processedMessagesKey) - } - - if let timestamp = lastProcessedTimestamp { - userDefaults.set(timestamp.timeIntervalSince1970, forKey: lastProcessedTimestampKey) - } - - userDefaults.synchronize() - } - - private func trimOldestMessages() { - // Since we don't track insertion order, we'll just keep the most recent N messages - // In a production app, you might want to track timestamps for each message - let excess = processedMessageIDs.count - maxStoredMessages - if excess > 0 { - // Remove random excess messages (not ideal, but simple) - for _ in 0.. PeripheralState + private var peerToPeripheralUUID: [String: String] = [:] // PeerID -> Peripheral UUID + + // 2. BLE Centrals (when acting as peripheral) + private var subscribedCentrals: [CBCentral] = [] + private var centralToPeerID: [String: String] = [:] // Central UUID -> Peer ID mapping + + // 3. Peer Information (single source of truth) + private struct PeerInfo { + let id: String + var nickname: String + var isConnected: Bool + var noisePublicKey: Data? + var lastSeen: Date + } + private var peers: [String: PeerInfo] = [:] + + // 4. Efficient Message Deduplication + private let messageDeduplicator = MessageDeduplicator() + + // 5. Fragment Reassembly (necessary for messages > MTU) + private var incomingFragments: [String: [Int: Data]] = [:] + private var fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)] = [:] + + // Simple announce throttling + private var lastAnnounceSent = Date.distantPast + private let announceMinInterval: TimeInterval = 0.5 + + // Application state tracking (thread-safe) + #if os(iOS) + private var isAppActive: Bool = true // Assume active initially + #endif + + // MARK: - Core BLE Objects + + private var centralManager: CBCentralManager? + private var peripheralManager: CBPeripheralManager? + private var characteristic: CBMutableCharacteristic? + + // MARK: - Identity + + var myPeerID: String = "" + var myNickname: String = "Anonymous" + private let noiseService = NoiseEncryptionService() + + // MARK: - Queues + + private let messageQueue = DispatchQueue(label: "mesh.message", attributes: .concurrent) + private let collectionsQueue = DispatchQueue(label: "mesh.collections", attributes: .concurrent) + private let messageQueueKey = DispatchSpecificKey() + private let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated) + + // Queue for messages pending handshake completion + private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:] + + // Queue for notifications that failed due to full queue + private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = [] + + // MARK: - Maintenance Timer + + 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 + } + 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 + ) + } + + // MARK: - Delegate + + weak var delegate: BitchatDelegate? + + // MARK: - Initialization + + /// Notify UI on main thread (only if needed) + private func notifyUI(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async(execute: block) + } + } + + override init() { + super.init() + + // Generate ephemeral peer ID (8 random bytes as hex) + self.myPeerID = (0..<8).map { _ in String(format: "%02x", UInt8.random(in: 0...255)) }.joined() + + // Set queue key for identification + messageQueue.setSpecific(key: messageQueueKey, value: ()) + + // Set up Noise session establishment callback + // This ensures we send pending messages only when session is truly established + noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in + SecureLogger.log("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...", + category: SecureLogger.noise, level: .info) + // Send any messages that were queued during handshake + self?.messageQueue.async { [weak self] in + self?.sendPendingMessagesAfterHandshake(for: peerID) + } + } + + // Set up application state tracking (iOS only) + #if os(iOS) + // Check initial state on main thread + if Thread.isMainThread { + isAppActive = UIApplication.shared.applicationState == .active + } else { + DispatchQueue.main.sync { + isAppActive = UIApplication.shared.applicationState == .active + } + } + + // Observe application state changes + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + #endif + + // Initialize BLE on background queue to prevent main thread blocking + // This prevents app freezes during BLE operations + centralManager = CBCentralManager(delegate: self, queue: bleQueue) + peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue) + + // Single maintenance timer for all periodic tasks + maintenanceTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in + self?.performMaintenance() + } + + // Publish initial empty state + publishFullPeerData() + } + + func setNickname(_ nickname: String) { + self.myNickname = nickname + // Send announce to notify peers of nickname change (force send) + sendAnnounce(forceSend: true) + } + + deinit { + maintenanceTimer?.invalidate() + centralManager?.stopScan() + peripheralManager?.stopAdvertising() + #if os(iOS) + NotificationCenter.default.removeObserver(self) + #endif + } + + // MARK: - Application State Handlers (iOS) + + #if os(iOS) + @objc private func appDidBecomeActive() { + isAppActive = true + // Restart scanning with allow duplicates when app becomes active + if centralManager?.state == .poweredOn { + centralManager?.stopScan() + startScanning() + } + } + + @objc private func appDidEnterBackground() { + isAppActive = false + // Restart scanning without allow duplicates in background + if centralManager?.state == .poweredOn { + centralManager?.stopScan() + startScanning() + } + } + #endif + + // MARK: - Helper Functions for Peripheral Management + + private func getConnectedPeripherals() -> [CBPeripheral] { + return peripherals.values + .filter { $0.isConnected } + .map { $0.peripheral } + } + + private func getPeripheral(for peerID: String) -> CBPeripheral? { + guard let uuid = peerToPeripheralUUID[peerID], + let state = peripherals[uuid], + state.isConnected else { return nil } + return state.peripheral + } + + // MARK: - Core Public API + + func startServices() { + // Start BLE services if not already running + if centralManager?.state == .poweredOn { + centralManager?.scanForPeripherals( + withServices: [SimplifiedBluetoothService.serviceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + } + + // Send initial announce after services are ready + // Use longer delay to avoid conflicts with other announces + messageQueue.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.sendAnnounce(forceSend: true) + } + } + + func stopServices() { + // Send leave message synchronously to ensure delivery + let leavePacket = BitchatPacket( + type: MessageType.leave.rawValue, + senderID: hexStringToData(myPeerID), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data(), + signature: nil, + ttl: messageTTL + ) + + // Send immediately to all connected peers + if let data = leavePacket.toBinaryData() { + // Send to peripherals we're connected to as central + for state in peripherals.values where state.isConnected { + if let characteristic = state.characteristic { + state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse) + } + } + + // Send to centrals subscribed to us as peripheral + if subscribedCentrals.count > 0 && characteristic != nil { + peripheralManager?.updateValue(data, for: characteristic!, onSubscribedCentrals: nil) + } + } + + // Give leave message a moment to send + Thread.sleep(forTimeInterval: 0.05) + + // Clear pending notifications + collectionsQueue.sync(flags: .barrier) { + pendingNotifications.removeAll() + } + + // Stop timer + maintenanceTimer?.invalidate() + maintenanceTimer = nil + + centralManager?.stopScan() + peripheralManager?.stopAdvertising() + + // Disconnect all peripherals + for state in peripherals.values { + centralManager?.cancelPeripheralConnection(state.peripheral) + } + } + + func isPeerConnected(_ peerID: String) -> Bool { + return collectionsQueue.sync { + return peers[peerID]?.isConnected ?? false + } + } + + func getPeerNicknames() -> [String: String] { + return collectionsQueue.sync { + Dictionary(uniqueKeysWithValues: peers.compactMap { (id, info) in + info.isConnected ? (id, info.nickname) : nil + }) + } + } + + func sendPrivateMessage(_ content: String, to recipientID: String, recipientNickname: String, messageID: String) { + sendPrivateMessage(content, to: recipientID, messageID: messageID) + } + + func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { + SecureLogger.log("🔔 sendFavoriteNotification called - peerID: \(peerID), isFavorite: \(isFavorite)", + category: SecureLogger.session, level: .info) + + // Include Nostr public key in the notification + var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]" + + // Add our Nostr public key if available + if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() { + content += ":" + myNostrIdentity.npub + SecureLogger.log("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", + category: SecureLogger.session, level: .info) + } + + SecureLogger.log("📤 Sending favorite notification to \(peerID): \(content)", + category: SecureLogger.session, level: .info) + sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString) + } + + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { + // Send encrypted read receipt + guard noiseService.hasSession(with: peerID) else { + SecureLogger.log("Cannot send read receipt - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning) + return + } + + SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", + category: SecureLogger.session, level: .info) + + // Create read receipt payload: [type byte] + [message ID] + var receiptPayload = Data([NoisePayloadType.readReceipt.rawValue]) + receiptPayload.append(contentsOf: receipt.originalMessageID.utf8) + + do { + let encrypted = try noiseService.encrypt(receiptPayload, for: peerID) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: hexStringToData(myPeerID), + recipientID: hexStringToData(peerID), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + + // If already on messageQueue, call directly + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + broadcastPacket(packet) + } else { + messageQueue.async { [weak self] in + self?.broadcastPacket(packet) + } + } + + // Read receipt sent + } catch { + SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error) + } + } + + func sendBroadcastAnnounce() { + sendAnnounce() + } + + func getPeerFingerprint(_ peerID: String) -> String? { + return collectionsQueue.sync { + if let publicKey = peers[peerID]?.noisePublicKey { + return dataToHexString(publicKey) + } + return nil + } + } + + func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { + if noiseService.hasEstablishedSession(with: peerID) { + return .established + } else if noiseService.hasSession(with: peerID) { + return .handshaking + } else { + return .none + } + } + + func triggerHandshake(with peerID: String) { + initiateNoiseHandshake(with: peerID) + } + + func emergencyDisconnectAll() { + stopServices() + + // Clear all sessions and peers + collectionsQueue.sync(flags: .barrier) { + peers.removeAll() + incomingFragments.removeAll() + fragmentMetadata.removeAll() + } + + // Clear processed messages + messageDeduplicator.reset() + + // Clear peripheral references + peripherals.removeAll() + peerToPeripheralUUID.removeAll() + subscribedCentrals.removeAll() + centralToPeerID.removeAll() + } + + func getNoiseService() -> NoiseEncryptionService { + return noiseService + } + + func getFingerprint(for peerID: String) -> String? { + return getPeerFingerprint(peerID) + } + + func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) { + // Ensure this runs on message queue to avoid main thread blocking + messageQueue.async { [weak self] in + guard let self = self else { return } + + guard content.count <= self.maxMessageLength else { + SecureLogger.log("Message too long: \(content.count) chars", category: SecureLogger.session, level: .error) + return + } + + let finalMessageID = messageID ?? UUID().uuidString + let _ = UInt64(Date().timeIntervalSince1970 * 1000) + + if let recipientID = recipientID { + // Private message + self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID) + } else { + // Public broadcast + // Public message - logged at relay point for mesh debugging + let packet = BitchatPacket( + type: MessageType.message.rawValue, + ttl: self.messageTTL, + senderID: self.myPeerID, + payload: Data(content.utf8) + ) + // Call synchronously since we're already on background queue + self.broadcastPacket(packet) + } + } + } + + func getPeers() -> [String: String] { + collectionsQueue.sync { + Dictionary(uniqueKeysWithValues: peers.compactMap { (id, info) in + info.isConnected ? (id, info.nickname) : nil + }) + } + } + + // MARK: - Private Message Handling + + private func sendPrivateMessage(_ content: String, to recipientID: String, messageID: String) { + SecureLogger.log("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: SecureLogger.session, level: .info) + + // Check if we have an established Noise session + if noiseService.hasEstablishedSession(with: recipientID) { + // Encrypt and send + do { + // Create TLV-encoded private message + let privateMessage = PrivateMessagePacket(messageID: messageID, content: content) + guard let tlvData = privateMessage.encode() else { + SecureLogger.log("Failed to encode private message with TLV", category: SecureLogger.noise, level: .error) + return + } + + // Create message payload with TLV: [type byte] + [TLV data] + var messagePayload = Data([NoisePayloadType.privateMessage.rawValue]) + messagePayload.append(tlvData) + + let encrypted = try noiseService.encrypt(messagePayload, for: recipientID) + + // Convert recipientID to Data (assuming it's a hex string) + var recipientData = Data() + var tempID = recipientID + while tempID.count >= 2 { + let hexByte = String(tempID.prefix(2)) + if let byte = UInt8(hexByte, radix: 16) { + recipientData.append(byte) + } + tempID = String(tempID.dropFirst(2)) + } + if tempID.count == 1 { + if let byte = UInt8(tempID, radix: 16) { + recipientData.append(byte) + } + } + + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: hexStringToData(myPeerID), + 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) + } else { + messageQueue.async { [weak self] in + self?.broadcastPacket(packet) + } + } + + // Notify delegate that message was sent + notifyUI { [weak self] in + self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) + } + } catch { + SecureLogger.log("Failed to encrypt message: \(error)", category: SecureLogger.noise, level: .error) + } + } else { + // Queue message for sending after handshake completes + SecureLogger.log("🤝 No session with \(recipientID), initiating handshake and queueing message", category: SecureLogger.session, level: .info) + + // Queue the message (especially important for favorite notifications) + collectionsQueue.sync(flags: .barrier) { + if pendingMessagesAfterHandshake[recipientID] == nil { + pendingMessagesAfterHandshake[recipientID] = [] + } + pendingMessagesAfterHandshake[recipientID]?.append((content, messageID)) + } + + initiateNoiseHandshake(with: recipientID) + + // Notify delegate that message is pending + notifyUI { [weak self] in + self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending) + } + } + } + + private func initiateNoiseHandshake(with peerID: String) { + // Use NoiseEncryptionService for handshake + guard !noiseService.hasSession(with: peerID) else { return } + + do { + let handshakeData = try noiseService.initiateHandshake(with: peerID) + + // Send handshake init + let packet = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: hexStringToData(myPeerID), + recipientID: hexStringToData(peerID), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: handshakeData, + signature: nil, + ttl: messageTTL + ) + // Call directly if on messageQueue, otherwise dispatch + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + broadcastPacket(packet) + } else { + messageQueue.async { [weak self] in + self?.broadcastPacket(packet) + } + } + } catch { + SecureLogger.log("Failed to initiate handshake: \(error)", category: SecureLogger.noise, level: .error) + } + } + + private func sendPendingMessagesAfterHandshake(for peerID: String) { + // Get and clear pending messages for this peer + let pendingMessages = collectionsQueue.sync(flags: .barrier) { () -> [(content: String, messageID: String)]? in + let messages = pendingMessagesAfterHandshake[peerID] + pendingMessagesAfterHandshake.removeValue(forKey: peerID) + return messages + } + + guard let messages = pendingMessages, !messages.isEmpty else { return } + + SecureLogger.log("📤 Sending \(messages.count) pending messages after handshake to \(peerID)", + category: SecureLogger.session, level: .info) + + // 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] + var messagePayload = Data([NoisePayloadType.privateMessage.rawValue]) + let messageWithID = "ID:\(messageID)|\(content)" + messagePayload.append(contentsOf: messageWithID.utf8) + + let encrypted = try noiseService.encrypt(messagePayload, for: peerID) + + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: hexStringToData(myPeerID), + recipientID: hexStringToData(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: .info) + } 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")) + } + } + } + } + + // MARK: - Packet Broadcasting + + private func broadcastPacket(_ packet: BitchatPacket) { + guard let data = packet.toBinaryData() else { + SecureLogger.log("❌ Failed to convert packet to binary data", category: SecureLogger.session, level: .error) + return + } + + // Only log broadcasts for non-announce packets + // Log encrypted and relayed packets for debugging + if packet.type == MessageType.noiseEncrypted.rawValue { + SecureLogger.log("📡 Encrypted packet to \(packet.recipientID?.hexEncodedString() ?? "unknown")", + category: SecureLogger.session, level: .info) + } else if packet.ttl < messageTTL { + // Relayed packet + } + + // Check if application-level fragmentation needed for large messages + // (CoreBluetooth only handles ATT-level fragmentation for single writes) + if data.count > 512 && packet.type != MessageType.fragment.rawValue { + sendFragmentedPacket(packet) + return + } + + // For private encrypted messages (not handshakes), send to specific peer only + // Handshakes need broader delivery to establish encryption + if packet.type == MessageType.noiseEncrypted.rawValue, + let recipientID = packet.recipientID { + let recipientPeerID = dataToHexString(recipientID) + var sentEncrypted = false + + // Check routing availability (only log if there's an issue) + let hasPeripheral = peerToPeripheralUUID[recipientPeerID] != nil + let hasCentral = centralToPeerID.values.contains(recipientPeerID) + + // Try to send directly to the specific peer as peripheral first + if let peripheralUUID = peerToPeripheralUUID[recipientPeerID], + let state = peripherals[peripheralUUID], + state.isConnected, + let characteristic = state.characteristic { + state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse) + // Successfully routed via peripheral + sentEncrypted = true + } + + // Also try notification if peer is connected as central (dual-role support) + if let characteristic = characteristic { + // Find the specific central for this peer + for central in subscribedCentrals { + if centralToPeerID[central.identifier.uuidString] == recipientPeerID { + let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false + if success { + // Successfully routed via central notification + sentEncrypted = true + } else { + // Queue for retry when notification queue has space + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + // Limit queue size to prevent memory issues + if self.pendingNotifications.count < 20 { + self.pendingNotifications.append((data: data, centrals: [central])) + SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)", + category: SecureLogger.session, level: .debug) + } else { + SecureLogger.log("⚠️ Pending notification queue full, dropping packet", + category: SecureLogger.session, level: .warning) + } + } + } + } + } + + // Do NOT broadcast encrypted messages to all centrals + // Encrypted messages must only go to the intended recipient + } + + if !sentEncrypted { + // Log detailed routing failure for debugging + SecureLogger.log("⚠️ Failed to route encrypted message to \(recipientPeerID) - peripheral=\(hasPeripheral) central=\(hasCentral)", + category: SecureLogger.session, level: .warning) + } + + return + } + + // For broadcast messages, use the original simple routing + // This ensures announces can be sent before peer ID mappings are established + var sentToPeripherals = 0 + var sentToCentrals = 0 + + // 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 { + state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse) + sentToPeripherals += 1 + } + } + + // 2. Also send via notifications to subscribed centrals + // This ensures all connected peers receive the message regardless of their connection role + // Broadcast message types that should go to all peers + // Include handshakes since they need to reach peers to establish encryption + let isBroadcastType = packet.type == MessageType.announce.rawValue || + packet.type == MessageType.message.rawValue || + packet.type == MessageType.leave.rawValue || + packet.type == MessageType.noiseHandshake.rawValue + if isBroadcastType, let characteristic = characteristic, !subscribedCentrals.isEmpty { + let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false + if success { + sentToCentrals = subscribedCentrals.count + if packet.type == MessageType.message.rawValue { + // Broadcast message sent + } else if packet.type == MessageType.noiseHandshake.rawValue { + // Handshake broadcast to centrals + } + } else { + // Notification queue full - queue for retry on handshake packets + if packet.type == MessageType.noiseHandshake.rawValue { + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + if self.pendingNotifications.count < 20 { + self.pendingNotifications.append((data: data, centrals: nil)) + SecureLogger.log("📋 Queued handshake packet for retry (notification queue full)", + category: SecureLogger.session, level: .debug) + } + } + } else { + SecureLogger.log("⚠️ Notification queue full for packet type \(packet.type)", + category: SecureLogger.session, level: .warning) + } + } + } + + let totalSent = sentToPeripherals + sentToCentrals + if totalSent == 0 { + // No peers to send to - this is normal when isolated + } else { + // Broadcast sent + } + } + + private func sendData(_ data: Data, to peripheral: CBPeripheral) { + // Fire-and-forget: Simple send without complex fallback logic + guard peripheral.state == .connected else { return } + + let peripheralUUID = peripheral.identifier.uuidString + guard let state = peripherals[peripheralUUID], + let characteristic = state.characteristic else { return } + + // Fire-and-forget principle: always use .withoutResponse for speed + // CoreBluetooth will handle fragmentation at L2CAP layer + peripheral.writeValue(data, for: characteristic, type: .withoutResponse) + } + + // MARK: - Fragmentation (Required for messages > BLE MTU) + + private func sendFragmentedPacket(_ packet: BitchatPacket) { + guard let fullData = packet.toBinaryData() else { return } + + let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) + let fragments = stride(from: 0, to: fullData.count, by: maxFragmentSize).map { offset in + Data(fullData[offset.. 13 else { return } + + let fragmentID = packet.payload[0..<8].map { String(format: "%02x", $0) }.joined() + let index = Int(packet.payload[8..<10].withUnsafeBytes { $0.load(as: UInt16.self).bigEndian }) + let total = Int(packet.payload[10..<12].withUnsafeBytes { $0.load(as: UInt16.self).bigEndian }) + let originalType = packet.payload[12] + let fragmentData = packet.payload[13...] + + // Store fragment + if incomingFragments[fragmentID] == nil { + incomingFragments[fragmentID] = [:] + fragmentMetadata[fragmentID] = (originalType, total, Date()) + } + incomingFragments[fragmentID]?[index] = Data(fragmentData) + + // Check if complete + if let fragments = incomingFragments[fragmentID], + fragments.count == total { + // Reassemble + var reassembled = Data() + for i in 0.. 0 ? packet.ttl - 1 : 0 + ) + handleReceivedPacket(reassembledPacket, from: peerID) + } + + // Cleanup + incomingFragments.removeValue(forKey: fragmentID) + fragmentMetadata.removeValue(forKey: fragmentID) + } + } + + // MARK: - Packet Reception + + private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: String) { + // Deduplication (thread-safe) + let senderID = dataToHexString(packet.senderID) + // Include packet type in message ID to prevent collisions between different packet types + let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)" + + // Only log non-announce packets to reduce noise + if packet.type != MessageType.announce.rawValue { + // Log packet details for debugging + SecureLogger.log("📦 Handling packet type \(packet.type) from \(senderID), messageID: \(messageID)", + category: SecureLogger.session, level: .debug) + } + + // Efficient deduplication + if messageDeduplicator.isDuplicate(messageID) { + // Announce packets (type 1) are sent every 10 seconds for peer discovery + // It's normal to see these as duplicates - don't log them to reduce noise + if packet.type != MessageType.announce.rawValue { + SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)", + category: SecureLogger.session, level: .debug) + } + return // Duplicate ignored + } + + // Update peer info without verbose logging - update the peer we received from, not the original sender + updatePeerLastSeen(peerID) + + + // Process by type + switch MessageType(rawValue: packet.type) { + case .announce: + handleAnnounce(packet, from: senderID) + + case .message: + handleMessage(packet, from: senderID) + + case .noiseHandshake: + handleNoiseHandshake(packet, from: senderID) + + case .noiseEncrypted: + handleNoiseEncrypted(packet, from: senderID) + + case .fragment: + handleFragment(packet, from: senderID) + + case .leave: + handleLeave(packet, from: senderID) + + default: + SecureLogger.log("⚠️ Unknown message type: \(packet.type)", category: SecureLogger.session, level: .warning) + break + } + + // Relay if TTL > 1 and we're not the original sender + // Do this asynchronously to avoid blocking and potential loops + // BUT: Don't relay private encrypted messages (they have a specific recipient) + let shouldRelay = packet.ttl > 1 && + senderID != myPeerID && + packet.type != MessageType.noiseEncrypted.rawValue + + if shouldRelay { + messageQueue.async { [weak self] in + var relayPacket = packet + relayPacket.ttl -= 1 + // Relaying packet + self?.broadcastPacket(relayPacket) + } + } + } + + private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) { + guard let announcement = AnnouncementPacket.decode(from: packet.payload) else { + SecureLogger.log("❌ Failed to decode announce packet from \(peerID)", category: SecureLogger.session, level: .error) + return + } + + // Don't add ourselves as a peer + if peerID == myPeerID { + return + } + + // Suppress announce logs to reduce noise + + // Track if this is a new or reconnected peer + var isNewPeer = false + var isReconnectedPeer = false + + collectionsQueue.sync(flags: .barrier) { + // Check if we have an actual BLE connection to this peer + let peripheralUUID = peerToPeripheralUUID[peerID] + _ = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true // hasPeripheralConnection + + // Check if this peer is subscribed to us as a central + // Note: We can't identify which specific central is which peer without additional mapping + _ = !subscribedCentrals.isEmpty // hasCentralSubscription + + // Check if we already have this peer (might be reconnecting) + let existingPeer = peers[peerID] + let wasDisconnected = existingPeer?.isConnected == false + + // Set flags for use outside the sync block + isNewPeer = (existingPeer == nil) + isReconnectedPeer = wasDisconnected + + // Update or create peer info + if let existing = existingPeer, existing.isConnected { + // Peer already connected, just update lastSeen to keep connection alive + peers[peerID] = PeerInfo( + id: existing.id, + nickname: announcement.nickname, // Update nickname in case it changed + isConnected: true, + noisePublicKey: announcement.publicKey, + lastSeen: Date() // Update timestamp to prevent timeout + ) + } else { + // New peer or reconnecting peer + peers[peerID] = PeerInfo( + id: peerID, + nickname: announcement.nickname, + isConnected: true, // If we received their announce, we're connected + noisePublicKey: announcement.publicKey, + lastSeen: Date() + ) + } + + // Log connection status + if existingPeer == nil { + SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .info) + } else if wasDisconnected { + SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .info) + } else if existingPeer?.nickname != announcement.nickname { + SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .info) + } + } + + // Notify UI on main thread + notifyUI { [weak self] in + guard let self = self else { return } + + // Get current peer list (after addition) + let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } + + // Only notify of connection for new or reconnected peers + if isNewPeer || isReconnectedPeer { + self.delegate?.didConnectToPeer(peerID) + } + + self.peersPublisher.send(self.getPeers()) + self.publishFullPeerData() // NEW: Publish full peer data + self.delegate?.didUpdatePeerList(currentPeerIDs) + } + + // Send announce back for bidirectional discovery (only once per peer) + let announceBackID = "announce-back-\(peerID)" + let shouldSendBack = !messageDeduplicator.contains(announceBackID) + if shouldSendBack { + messageDeduplicator.markProcessed(announceBackID) + } + + if shouldSendBack { + // Reciprocate announce for bidirectional discovery + // Force send to ensure the peer receives our announce + sendAnnounce(forceSend: true) + } + } + + 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 + } + + private func handleMessage(_ packet: BitchatPacket, from peerID: String) { + // Don't process our own messages + if peerID == myPeerID { + return + } + + guard let content = String(data: packet.payload, encoding: .utf8) else { + SecureLogger.log("❌ Failed to decode message payload as UTF-8", category: SecureLogger.session, level: .error) + return + } + + let senderNickname = peers[peerID]?.nickname ?? "Unknown" + SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .info) + + // 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) + notifyUI { [weak self] in + guard let self = self else { return } + // Deliver to UI + self.messagesPublisher.send(message) + self.delegate?.didReceiveMessage(message) + } + } + + private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: String) { + // Use NoiseEncryptionService for handshake processing + if let recipientID = packet.recipientID, + dataToHexString(recipientID) == 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), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: response, + signature: nil, + ttl: messageTTL + ) + // We're on messageQueue from delegate callback + broadcastPacket(responsePacket) + } + + // Session establishment will trigger onPeerAuthenticated callback + // which will send any pending messages at the right time + } catch { + SecureLogger.log("Failed to process handshake: \(error)", category: SecureLogger.noise, level: .error) + // Try initiating a new handshake + if !noiseService.hasSession(with: peerID) { + initiateNoiseHandshake(with: peerID) + } + } + } + } + + private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: String) { + SecureLogger.log("🔐 handleNoiseEncrypted called for packet from \(peerID)", + category: SecureLogger.noise, level: .debug) + + guard let recipientID = packet.recipientID else { + SecureLogger.log("⚠️ Encrypted message has no recipient ID", category: SecureLogger.session, level: .warning) + return + } + + let recipientHex = dataToHexString(recipientID) + if recipientHex != myPeerID { + SecureLogger.log("🔐 Encrypted message not for me (for \(recipientHex), I am \(myPeerID))", category: SecureLogger.session, level: .debug) + return + } + + // Update lastSeen for the peer we received from (important for private messages) + updatePeerLastSeen(peerID) + + do { + let decrypted = try noiseService.decrypt(packet.payload, from: peerID) + guard decrypted.count > 0 else { return } + + // First byte indicates the payload type + let payloadType = decrypted[0] + let payloadData = decrypted.dropFirst() + + 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: .info) + + // Send on main thread + 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) + } + } + + // 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 + notifyUI { [weak self] in + self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: peerID, at: Date())) + } + + 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: .info) + + // Update read status + notifyUI { [weak self] in + let nickname = self?.peers[peerID]?.nickname ?? "Unknown" + self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .read(by: nickname, at: Date())) + } + + default: + SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning) + } + } catch { + SecureLogger.log("❌ Failed to decrypt message from \(peerID): \(error)", + category: SecureLogger.noise, level: .error) + } + } + + private func handleLeave(_ packet: BitchatPacket, from peerID: String) { + _ = collectionsQueue.sync(flags: .barrier) { + // Remove the peer when they leave + peers.removeValue(forKey: peerID) + } + // Send on main thread + notifyUI { [weak self] in + guard let self = self else { return } + + // 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) + } + } + + // MARK: - Helper Functions + + private func sendLeave() { + SecureLogger.log("👋 Sending leave announcement", category: SecureLogger.session, level: .info) + let packet = BitchatPacket( + type: MessageType.leave.rawValue, + ttl: messageTTL, + senderID: myPeerID, + payload: Data(myNickname.utf8) + ) + broadcastPacket(packet) + } + + private func sendAnnounce(forceSend: Bool = false) { + // Throttle announces to prevent flooding + let now = Date() + let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent) + + // Even forced sends should respect a minimum interval to avoid overwhelming BLE + let minInterval = forceSend ? 0.1 : announceMinInterval // Reduced from 0.2 for faster reconnection + + if timeSinceLastAnnounce < minInterval { + // Skipping announce (rate limited) + return + } + lastAnnounceSent = now + + // Reduced logging - only log errors, not every announce + + let announcement = AnnouncementPacket( + nickname: myNickname, + publicKey: noiseService.getStaticPublicKeyData() + ) + + guard let payload = announcement.encode() else { + SecureLogger.log("❌ Failed to encode announce packet", category: SecureLogger.session, level: .error) + return + } + + let packet = BitchatPacket( + type: MessageType.announce.rawValue, + ttl: messageTTL, + senderID: myPeerID, + payload: payload + ) + + // Call directly if on messageQueue, otherwise dispatch + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + broadcastPacket(packet) + } else { + messageQueue.async { [weak self] in + self?.broadcastPacket(packet) + } + } + } + + private 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) + return + } + + // Create ACK payload: [type byte] + [message ID] + var ackPayload = Data([NoisePayloadType.delivered.rawValue]) + ackPayload.append(contentsOf: messageID.utf8) + + do { + let encrypted = try noiseService.encrypt(ackPayload, for: peerID) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: hexStringToData(myPeerID), + recipientID: hexStringToData(peerID), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + broadcastPacket(packet) + // Delivery ACK sent + } catch { + SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error) + } + } + + private func updatePeerLastSeen(_ peerID: String) { + // Use async to avoid deadlock - we don't need immediate consistency for last seen updates + collectionsQueue.async(flags: .barrier) { + if var peer = self.peers[peerID] { + peer.lastSeen = Date() + self.peers[peerID] = peer + } + } + } + + // NEW: Publish full peer data to subscribers + private func publishFullPeerData() { + let snapshot = collectionsQueue.sync { () -> [String: PeerInfoSnapshot] in + Dictionary(uniqueKeysWithValues: peers.map { (id, info) in + (id, createPeerSnapshot(info)) + }) + } + fullPeersPublisher.send(snapshot) + } + + // MARK: - Consolidated Maintenance + + private func performMaintenance() { + maintenanceCounter += 1 + + // Always: Send keep-alive announce (every 10 seconds) + sendAnnounce(forceSend: true) + + // If we have no peers, ensure we're scanning and advertising + if peers.isEmpty { + // Ensure we're advertising as peripheral + if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising { + pm.startAdvertising([ + CBAdvertisementDataServiceUUIDsKey: [SimplifiedBluetoothService.serviceUUID], + CBAdvertisementDataLocalNameKey: myPeerID + ]) + } + } + + // Every 20 seconds (2 cycles): Check peer connectivity + if maintenanceCounter % 2 == 0 { + checkPeerConnectivity() + } + + // Every 30 seconds (3 cycles): Cleanup + if maintenanceCounter % 3 == 0 { + performCleanup() + } + + // Reset counter to prevent overflow (every 60 seconds) + if maintenanceCounter >= 6 { + maintenanceCounter = 0 + } + } + + private func checkPeerConnectivity() { + let now = Date() + var disconnectedPeers: [String] = [] + + collectionsQueue.sync(flags: .barrier) { + for (peerID, peer) in peers { + if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > 20 { + // Check if we still have an active BLE connection to this peer + let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil && + peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true + let hasCentralConnection = centralToPeerID.values.contains(peerID) + + // Only remove if we don't have an active BLE connection + if !hasPeripheralConnection && !hasCentralConnection { + // Remove the peer completely (they'll be re-added when they reconnect) + SecureLogger.log("⏱️ Peer timed out (no packets for 20s): \(peerID) (\(peer.nickname))", + category: SecureLogger.session, level: .info) + peers.removeValue(forKey: peerID) + disconnectedPeers.append(peerID) + } + } + } + } + + // Update UI if any peers were disconnected + if !disconnectedPeers.isEmpty { + notifyUI { [weak self] in + guard let self = self else { return } + + // Get current peer list (after removal) + let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } + + for peerID in disconnectedPeers { + self.delegate?.didDisconnectFromPeer(peerID) + } + self.peersPublisher.send(self.getPeers()) + self.delegate?.didUpdatePeerList(currentPeerIDs) + } + } + } + + private func performCleanup() { + let now = Date() + + // Clean old processed messages efficiently + messageDeduplicator.cleanup() + + // Clean old fragments (> 30 seconds old) + collectionsQueue.sync(flags: .barrier) { + let cutoff = now.addingTimeInterval(-30) + let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key } + for fragmentID in oldFragments { + incomingFragments.removeValue(forKey: fragmentID) + fragmentMetadata.removeValue(forKey: fragmentID) + } + } + } +} + +// MARK: - CBCentralManagerDelegate + +extension SimplifiedBluetoothService: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + if central.state == .poweredOn { + // Start scanning - use allow duplicates for faster discovery when active + startScanning() + } + } + + private func startScanning() { + guard let central = centralManager, + central.state == .poweredOn, + !central.isScanning else { return } + + // Use allow duplicates = true for faster discovery in foreground + // This gives us discovery events immediately instead of coalesced + #if os(iOS) + let allowDuplicates = isAppActive // Use our tracked state (thread-safe) + #else + let allowDuplicates = true // macOS doesn't have background restrictions + #endif + + central.scanForPeripherals( + withServices: [SimplifiedBluetoothService.serviceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates] + ) + + // Started BLE scanning + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { + let peripheralID = peripheral.identifier.uuidString + let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "Unknown" + let rssiValue = RSSI.intValue + + // Skip if signal too weak - prevents connection attempts at extreme range + guard rssiValue > -90 else { + // Too far away, don't attempt connection + return + } + + // Check if we already have this peripheral + if let state = peripherals[peripheralID] { + if state.isConnected || state.isConnecting { + return // Already connected or connecting + } + + // Add backoff for reconnection attempts + if let lastAttempt = state.lastConnectionAttempt { + let timeSinceLastAttempt = Date().timeIntervalSince(lastAttempt) + if timeSinceLastAttempt < 2.0 { + return // Wait at least 2 seconds between connection attempts + } + } + } + + // Check peripheral state - but cancel if stale + if peripheral.state == .connecting || peripheral.state == .connected { + // iOS might have stale state - force disconnect and retry + central.cancelPeripheralConnection(peripheral) + // Will retry on next discovery + return + } + + // Only log when we're actually attempting connection + // Discovered BLE peripheral + + // Store the peripheral and mark as connecting + peripherals[peripheralID] = PeripheralState( + peripheral: peripheral, + characteristic: nil, + peerID: nil, + isConnecting: true, + isConnected: false, + lastConnectionAttempt: Date() + ) + peripheral.delegate = self + + // Connect to the peripheral with options for faster connection + SecureLogger.log("📱 Connect: \(advertisedName) [RSSI:\(rssiValue)]", + category: SecureLogger.session, level: .info) + + // Use connection options for faster reconnection + let options: [String: Any] = [ + CBConnectPeripheralOptionNotifyOnConnectionKey: true, + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionNotifyOnNotificationKey: true + ] + central.connect(peripheral, options: options) + + // Set a timeout for the connection attempt + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in + guard let self = self, + let state = self.peripherals[peripheralID], + state.isConnecting && !state.isConnected else { return } + + // Connection timed out - cancel it + SecureLogger.log("⏱️ Timeout: \(advertisedName)", + category: SecureLogger.session, level: .warning) + central.cancelPeripheralConnection(peripheral) + self.peripherals[peripheralID] = nil + } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + let peripheralID = peripheral.identifier.uuidString + + // Update state to connected + if var state = peripherals[peripheralID] { + state.isConnecting = false + state.isConnected = true + peripherals[peripheralID] = state + } else { + // Create new state if not found + peripherals[peripheralID] = PeripheralState( + peripheral: peripheral, + characteristic: nil, + peerID: nil, + isConnecting: false, + isConnected: true + ) + } + + SecureLogger.log("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: SecureLogger.session, level: .info) + + // Discover services + peripheral.discoverServices([SimplifiedBluetoothService.serviceUUID]) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + let peripheralID = peripheral.identifier.uuidString + + // Find the peer ID if we have it + let peerID = peripherals[peripheralID]?.peerID + + SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", + category: SecureLogger.session, level: .info) + + // Clean up references + peripherals.removeValue(forKey: peripheralID) + + // Clean up peer mappings + if let peerID = peerID { + peerToPeripheralUUID.removeValue(forKey: peerID) + + // Remove peer completely (they'll be re-added when they reconnect and announce) + _ = collectionsQueue.sync(flags: .barrier) { + peers.removeValue(forKey: peerID) + } + } + + // Restart scanning with allow duplicates for faster rediscovery + if centralManager?.state == .poweredOn { + // Stop and restart scanning to ensure we get fresh discovery events + centralManager?.stopScan() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.startScanning() + } + } + + // Notify delegate about disconnection on main thread + notifyUI { [weak self] in + guard let self = self else { return } + + // Get current peer list (after removal) + let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } + + if let peerID = peerID { + self.delegate?.didDisconnectFromPeer(peerID) + } + self.peersPublisher.send(self.getPeers()) + self.publishFullPeerData() // NEW: Publish full peer data + self.delegate?.didUpdatePeerList(currentPeerIDs) + } + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + let peripheralID = peripheral.identifier.uuidString + + // Clean up the references + peripherals.removeValue(forKey: peripheralID) + + SecureLogger.log("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: SecureLogger.session, level: .error) + } +} + +// MARK: - CBPeripheralDelegate + +extension SimplifiedBluetoothService: 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]) + } + return + } + + guard let services = peripheral.services else { + SecureLogger.log("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .warning) + return + } + + guard let service = services.first(where: { $0.uuid == SimplifiedBluetoothService.serviceUUID }) else { + // Not a BitChat peer - disconnect + centralManager?.cancelPeripheralConnection(peripheral) + return + } + + // Discovering BLE characteristics + peripheral.discoverCharacteristics([SimplifiedBluetoothService.characteristicUUID], for: service) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error = error { + SecureLogger.log("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: SecureLogger.session, level: .error) + return + } + + guard let characteristic = service.characteristics?.first(where: { $0.uuid == SimplifiedBluetoothService.characteristicUUID }) else { + SecureLogger.log("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .warning) + return + } + + // Found characteristic + + // Log characteristic properties for debugging + var properties: [String] = [] + if characteristic.properties.contains(.read) { properties.append("read") } + if characteristic.properties.contains(.write) { properties.append("write") } + if characteristic.properties.contains(.writeWithoutResponse) { properties.append("writeWithoutResponse") } + if characteristic.properties.contains(.notify) { properties.append("notify") } + if characteristic.properties.contains(.indicate) { properties.append("indicate") } + // Characteristic properties: \(properties.joined(separator: ", ")) + + // Verify characteristic supports reliable writes + if !characteristic.properties.contains(.write) { + SecureLogger.log("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: SecureLogger.session, level: .warning) + } + + // Store characteristic in our consolidated structure + let peripheralID = peripheral.identifier.uuidString + if var state = peripherals[peripheralID] { + state.characteristic = characteristic + peripherals[peripheralID] = state + } + + // Subscribe for notifications + if characteristic.properties.contains(.notify) { + peripheral.setNotifyValue(true, for: characteristic) + SecureLogger.log("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .debug) + + // Send announce after subscription is confirmed (force send for new connection) + messageQueue.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.sendAnnounce(forceSend: true) + } + } else { + SecureLogger.log("⚠️ Characteristic does not support notifications", category: SecureLogger.session, level: .warning) + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + SecureLogger.log("❌ Error receiving notification: \(error.localizedDescription)", category: SecureLogger.session, level: .error) + return + } + + guard let data = characteristic.value else { + SecureLogger.log("⚠️ No data in notification", category: SecureLogger.session, level: .warning) + return + } + + // Received BLE notification + + // Process directly on main thread to avoid deadlocks (matches original implementation) + guard let packet = BinaryProtocol.decode(data) else { + SecureLogger.log("❌ Failed to decode notification packet, full data: \(data.map { String(format: "%02x", $0) }.joined(separator: " "))", + category: SecureLogger.session, level: .error) + return + } + + // Use the packet's senderID as the peer identifier + let senderID = dataToHexString(packet.senderID) + // 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) + } + + let peripheralUUID = peripheral.identifier.uuidString + + // Update mapping ONLY for announce packets that come directly from the peer (not relayed) + if packet.type == MessageType.announce.rawValue { + // Only update mapping if this is a direct announce (TTL == messageTTL means not relayed) + if packet.ttl == messageTTL { + if var state = peripherals[peripheralUUID] { + state.peerID = senderID + peripherals[peripheralUUID] = state + } + peerToPeripheralUUID[senderID] = peripheralUUID + // Mapping update - direct announce from peer + } + // Process the announce packet regardless of whether we updated the mapping + handleReceivedPacket(packet, from: senderID) + } else { + // For non-announce packets, DO NOT update mappings + // These could be relayed packets from other peers + // Always use the packet's original senderID + handleReceivedPacket(packet, from: senderID) + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + SecureLogger.log("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: SecureLogger.session, level: .error) + // Don't retry - just log the error + } else { + SecureLogger.log("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: SecureLogger.session, level: .debug) + } + } + + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + // Suppress verbose ready logs + } + + func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { + 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 + + if !hasOurService { + // Service is gone - disconnect + SecureLogger.log("❌ BitChat service removed - disconnecting from \(peripheral.name ?? peripheral.identifier.uuidString)", category: SecureLogger.session, level: .warning) + centralManager?.cancelPeripheralConnection(peripheral) + } else { + // Try to rediscover + peripheral.discoverServices([SimplifiedBluetoothService.serviceUUID]) + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + SecureLogger.log("❌ Error updating notification state: \(error.localizedDescription)", category: SecureLogger.session, level: .error) + } else { + SecureLogger.log("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: SecureLogger.session, level: .debug) + + // If notifications are now on, send an announce to ensure this peer knows about us + if characteristic.isNotifying { + // Sending announce after subscription + self.sendAnnounce(forceSend: true) + } + } + } +} + +// MARK: - CBPeripheralManagerDelegate + +extension SimplifiedBluetoothService: CBPeripheralManagerDelegate { + func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + SecureLogger.log("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: SecureLogger.session, level: .debug) + + if peripheral.state == .poweredOn { + // Remove all services first to ensure clean state + peripheral.removeAllServices() + + // Create characteristic + characteristic = CBMutableCharacteristic( + type: SimplifiedBluetoothService.characteristicUUID, + properties: [.notify, .write, .writeWithoutResponse, .read], + value: nil, + permissions: [.readable, .writeable] + ) + + // Create service + let service = CBMutableService(type: SimplifiedBluetoothService.serviceUUID, primary: true) + service.characteristics = [characteristic!] + + // Add service (advertising will start in didAdd delegate) + SecureLogger.log("🔧 Adding BLE service...", category: SecureLogger.session, level: .debug) + peripheral.add(service) + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { + if let error = error { + SecureLogger.log("❌ Failed to add service: \(error.localizedDescription)", category: SecureLogger.session, level: .error) + return + } + + SecureLogger.log("✅ Service added successfully, starting advertising", category: SecureLogger.session, level: .info) + + // Now start advertising after service is confirmed added + peripheral.startAdvertising([ + CBAdvertisementDataServiceUUIDsKey: [SimplifiedBluetoothService.serviceUUID], + CBAdvertisementDataLocalNameKey: myPeerID + ]) + + SecureLogger.log("📡 Started advertising as peripheral with ID: \(myPeerID)", category: SecureLogger.session, level: .info) + } + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { + SecureLogger.log("📥 Central subscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug) + subscribedCentrals.append(central) + // Send announce to the newly subscribed central after a delay to avoid overwhelming + // Sending announce to new subscriber + sendAnnounce(forceSend: true) + } + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { + SecureLogger.log("📤 Central unsubscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .info) + subscribedCentrals.removeAll { $0.identifier == central.identifier } + + // Ensure we're still advertising for other devices to find us + if peripheral.isAdvertising == false { + SecureLogger.log("📡 Restarting advertising after central unsubscribed", category: SecureLogger.session, level: .info) + peripheral.startAdvertising([ + CBAdvertisementDataServiceUUIDsKey: [SimplifiedBluetoothService.serviceUUID], + CBAdvertisementDataLocalNameKey: myPeerID + ]) + } + + // Find and disconnect the peer associated with this central + let centralUUID = central.identifier.uuidString + if let peerID = centralToPeerID[centralUUID] { + // Remove peer completely (they'll be re-added when they reconnect) + _ = collectionsQueue.sync(flags: .barrier) { + peers.removeValue(forKey: peerID) + } + + // Clean up mappings + centralToPeerID.removeValue(forKey: centralUUID) + + // Update UI immediately + notifyUI { [weak self] in + guard let self = self else { return } + + // Get current peer list (after removal) + let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } + + self.delegate?.didDisconnectFromPeer(peerID) + self.peersPublisher.send(self.getPeers()) + self.delegate?.didUpdatePeerList(currentPeerIDs) + } + } + } + + func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { + SecureLogger.log("📤 Peripheral manager ready to send more notifications", category: SecureLogger.session, level: .info) + + // Retry pending notifications now that queue has space + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self, + let characteristic = self.characteristic, + !self.pendingNotifications.isEmpty else { return } + + let pending = self.pendingNotifications + self.pendingNotifications.removeAll() + + // Try to send pending notifications + for (data, centrals) in pending { + if let centrals = centrals { + // Send to specific centrals + let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false + if !success { + // Still full, re-queue + self.pendingNotifications.append((data: data, centrals: centrals)) + SecureLogger.log("⚠️ Notification queue still full, re-queuing", + category: SecureLogger.session, level: .debug) + break // Stop trying, wait for next ready callback + } else { + SecureLogger.log("✅ Sent pending notification from retry queue", + category: SecureLogger.session, level: .debug) + } + } else { + // Broadcast to all + let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false + if !success { + // Still full, re-queue + self.pendingNotifications.append((data: data, centrals: nil)) + break + } + } + } + + if !self.pendingNotifications.isEmpty { + SecureLogger.log("📋 Still have \(self.pendingNotifications.count) pending notifications", + category: SecureLogger.session, level: .debug) + } + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { + // Suppress logs for single write requests to reduce noise + if requests.count > 1 { + SecureLogger.log("📥 Received \(requests.count) write requests from central", category: SecureLogger.session, level: .info) + } + + // IMPORTANT: Respond immediately to prevent timeouts! + // We must respond within a few milliseconds or the central will timeout + for request in requests { + peripheral.respond(to: request, withResult: .success) + } + + // Process directly on main thread to avoid deadlocks (matches original implementation) + for request in requests { + guard let data = request.value, !data.isEmpty else { + SecureLogger.log("⚠️ Empty write request", category: SecureLogger.session, level: .warning) + continue + } + + // Suppress logs for announce packets to reduce noise + // Peek at packet type without full decode + if data.count > 0 && data[0] != MessageType.announce.rawValue { + SecureLogger.log("📥 Processing write from central: \(data.count) bytes", category: SecureLogger.session, level: .debug) + } + + if let packet = BinaryProtocol.decode(data) { + // Use the packet's senderID as the peer identifier + let senderID = dataToHexString(packet.senderID) + // 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: .info) + } + + // Store central in our list if not already there + if !subscribedCentrals.contains(request.central) { + subscribedCentrals.append(request.central) + } + + let centralUUID = request.central.identifier.uuidString + + // Update mapping ONLY for announce packets that come directly from the peer (not relayed) + if packet.type == MessageType.announce.rawValue { + // Only update mapping if this is a direct announce (TTL == messageTTL means not relayed) + if packet.ttl == messageTTL { + centralToPeerID[centralUUID] = senderID + // Mapping update - direct announce from peer + } + // Process the announce packet regardless of whether we updated the mapping + handleReceivedPacket(packet, from: senderID) + } else { + // For non-announce packets, DO NOT update the mapping + // These could be relayed packets from other peers in the mesh + // The centralToPeerID mapping should only be set by announce packets + // Always use the packet's original senderID + handleReceivedPacket(packet, from: senderID) + } + } else { + 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: - 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.. = [] + @Published private(set) var favorites: [BitchatPeer] = [] + @Published private(set) var mutualFavorites: [BitchatPeer] = [] + + // MARK: - Private Properties + + private var peerIndex: [String: BitchatPeer] = [:] + private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint + private let meshService: SimplifiedBluetoothService + private let favoritesService = FavoritesPersistenceService.shared + private var cancellables = Set() + + // MARK: - Initialization + + init(meshService: SimplifiedBluetoothService) { + self.meshService = meshService + + // Subscribe to changes from both services + setupSubscriptions() + + // Perform initial update + Task { @MainActor in + updatePeers() + } + } + + // 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) + + // Also listen for favorite change notifications + NotificationCenter.default.publisher(for: .favoriteStatusChanged) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updatePeers() + } + .store(in: &cancellables) + } + + // MARK: - Core Update Logic + + private func updatePeers() { + let meshPeers = meshService.fullPeersPublisher.value + let favorites = favoritesService.favorites + + var enrichedPeers: [BitchatPeer] = [] + var connected: Set = [] + var addedPeerIDs: Set = [] + + // Phase 1: Add all connected mesh peers + for (peerID, peerInfo) in meshPeers where peerInfo.isConnected { + guard peerID != meshService.myPeerID else { continue } // Never add self + + let peer = buildPeerFromMesh( + peerID: peerID, + peerInfo: peerInfo, + favorites: favorites + ) + + enrichedPeers.append(peer) + connected.insert(peerID) + addedPeerIDs.insert(peerID) + + // Update fingerprint cache + if let publicKey = peerInfo.noisePublicKey { + fingerprintCache[peerID] = publicKey.sha256Fingerprint() + } + } + + // Phase 2: Add offline favorites that we actively favorite + for (favoriteKey, favorite) in favorites where favorite.isFavorite { + let peerID = favoriteKey.hexEncodedString() + + // Skip if already added (connected peer) + if addedPeerIDs.contains(peerID) { continue } + + // Skip if connected under different ID but same nickname + let isConnectedByNickname = enrichedPeers.contains { + $0.nickname == favorite.peerNickname && $0.isConnected + } + if isConnectedByNickname { continue } + + let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID) + enrichedPeers.append(peer) + addedPeerIDs.insert(peerID) + + // Update fingerprint cache + fingerprintCache[peerID] = favoriteKey.sha256Fingerprint() + } + + // Phase 3: Sort peers + enrichedPeers.sort { lhs, rhs in + // Connected first + if lhs.isConnected != rhs.isConnected { + return lhs.isConnected + } + // Then favorites + if lhs.isFavorite != rhs.isFavorite { + return lhs.isFavorite + } + // Finally alphabetical + return lhs.displayName < rhs.displayName + } + + // Phase 4: Build subsets and indices + var favoritesList: [BitchatPeer] = [] + var mutualsList: [BitchatPeer] = [] + var newIndex: [String: BitchatPeer] = [:] + + for peer in enrichedPeers { + newIndex[peer.id] = peer + + if peer.isFavorite { + favoritesList.append(peer) + } + if peer.isMutualFavorite { + mutualsList.append(peer) + } + } + + // Phase 5: Update published properties + self.peers = enrichedPeers + self.connectedPeerIDs = connected + self.favorites = favoritesList + self.mutualFavorites = mutualsList + self.peerIndex = newIndex + + // Log summary (commented out to reduce noise) + // let connectedCount = connected.count + // let offlineCount = enrichedPeers.count - connectedCount + // Peer update: \(enrichedPeers.count) total (\(connectedCount) connected, \(offlineCount) offline) + } + + // MARK: - Peer Building Helpers + + private func buildPeerFromMesh( + peerID: String, + peerInfo: SimplifiedBluetoothService.PeerInfoSnapshot, + favorites: [Data: FavoritesPersistenceService.FavoriteRelationship] + ) -> BitchatPeer { + var peer = BitchatPeer( + id: peerID, + noisePublicKey: peerInfo.noisePublicKey ?? Data(), + nickname: peerInfo.nickname, + lastSeen: peerInfo.lastSeen, + isConnected: true + ) + + // Check for favorite status + if let noiseKey = peerInfo.noisePublicKey, + let favoriteStatus = favorites[noiseKey] { + peer.favoriteStatus = favoriteStatus + peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey + } else { + // Check by nickname for reconnected peers + let favoriteByNickname = favorites.values.first { + $0.peerNickname == peerInfo.nickname + } + + if let favorite = favoriteByNickname, + let noiseKey = peerInfo.noisePublicKey { + SecureLogger.log( + "🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key", + category: SecureLogger.session, + level: .info + ) + + // Update the favorite's key in persistence + favoritesService.updateNoisePublicKey( + from: favorite.peerNoisePublicKey, + to: noiseKey, + peerNickname: peerInfo.nickname + ) + + // Get updated favorite + peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey) + peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey + } + } + + return peer + } + + private func buildPeerFromFavorite( + favorite: FavoritesPersistenceService.FavoriteRelationship, + peerID: String + ) -> BitchatPeer { + var peer = BitchatPeer( + id: peerID, + noisePublicKey: favorite.peerNoisePublicKey, + nickname: favorite.peerNickname, + lastSeen: favorite.lastUpdated, + isConnected: false + ) + + peer.favoriteStatus = favorite + peer.nostrPublicKey = favorite.peerNostrPublicKey + + return peer + } + + // MARK: - Public Methods + + /// Get peer by ID + func getPeer(by id: String) -> BitchatPeer? { + return peerIndex[id] + } + + /// Get peer ID for nickname + func getPeerID(for nickname: String) -> String? { + for peer in peers { + if peer.displayName == nickname || peer.nickname == nickname { + return peer.id + } + } + return nil + } + + /// Check if peer is online + func isOnline(_ peerID: String) -> Bool { + return connectedPeerIDs.contains(peerID) + } + + /// Check if peer is blocked + func isBlocked(_ peerID: String) -> Bool { + // Get fingerprint + guard let fingerprint = getFingerprint(for: peerID) else { return false } + + // Check SecureIdentityStateManager for block status + if let identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { + return identity.isBlocked + } + + return false + } + + /// Toggle favorite status + func toggleFavorite(_ peerID: String) { + guard let peer = getPeer(by: peerID) else { + SecureLogger.log("⚠️ Cannot toggle favorite - peer not found: \(peerID)", + category: SecureLogger.session, level: .warning) + return + } + + let wasFavorite = peer.isFavorite + + // Get the actual nickname for logging and saving + var actualNickname = peer.nickname + + // Debug logging to understand the issue + SecureLogger.log("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)", + category: SecureLogger.session, level: .info) + + if actualNickname.isEmpty { + // Try to get from mesh service's current peer list + if let meshPeerNickname = meshService.getPeerNicknames()[peerID] { + actualNickname = meshPeerNickname + SecureLogger.log("🔍 Got nickname from mesh service: '\(actualNickname)'", + category: SecureLogger.session, level: .debug) + } + } + + // Use displayName as fallback (which shows ID prefix if nickname is empty) + let finalNickname = actualNickname.isEmpty ? peer.displayName : actualNickname + + if wasFavorite { + // Remove favorite + favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey) + } else { + // Get or derive peer's Nostr public key if not already known + var peerNostrKey = peer.nostrPublicKey + if peerNostrKey == nil { + // Try to get from NostrIdentityBridge association + peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey) + } + + // Add favorite + favoritesService.addFavorite( + peerNoisePublicKey: peer.noisePublicKey, + peerNostrPublicKey: peerNostrKey, + peerNickname: finalNickname + ) + } + + // Log the final nickname being saved + SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", + category: SecureLogger.session, level: .info) + + // Send favorite notification to the peer + meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite) + + // Force update of peers to reflect the change + updatePeers() + + // Force UI update by notifying SwiftUI directly + DispatchQueue.main.async { [weak self] in + self?.objectWillChange.send() + } + } + + /// Toggle blocked status + func toggleBlocked(_ peerID: String) { + guard let fingerprint = getFingerprint(for: peerID) else { return } + + // Get or create social identity + var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) + ?? SocialIdentity( + fingerprint: fingerprint, + localPetname: nil, + claimedNickname: getPeer(by: peerID)?.displayName ?? "Unknown", + trustLevel: .unknown, + isFavorite: false, + isBlocked: false, + notes: nil + ) + + // Toggle blocked status + identity.isBlocked = !identity.isBlocked + + // Can't be both favorite and blocked + if identity.isBlocked { + identity.isFavorite = false + // Also remove from favorites service + if let peer = getPeer(by: peerID) { + favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey) + } + } + + SecureIdentityStateManager.shared.updateSocialIdentity(identity) + } + + /// Get fingerprint for peer ID + func getFingerprint(for peerID: String) -> String? { + // Check cache first + if let cached = fingerprintCache[peerID] { + return cached + } + + // Try to get from mesh service + if let fingerprint = meshService.getPeerFingerprint(peerID) { + fingerprintCache[peerID] = fingerprint + return fingerprint + } + + // Try to get from peer's public key + if let peer = getPeer(by: peerID) { + let fingerprint = peer.noisePublicKey.sha256Fingerprint() + fingerprintCache[peerID] = fingerprint + return fingerprint + } + + return nil + } + + // MARK: - Compatibility Methods (for easy migration) + + var allPeers: [BitchatPeer] { peers } + var connectedPeers: [String] { Array(connectedPeerIDs) } + var favoritePeers: Set { + Set(favorites.compactMap { getFingerprint(for: $0.id) }) + } + var blockedUsers: Set { + Set(peers.compactMap { peer in + isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil + }) + } +} + +// MARK: - Helper Extensions + +extension Data { + func sha256Fingerprint() -> String { + // Implementation matches existing fingerprint generation in NoiseEncryptionService + let hash = SHA256.hash(data: self) + return hash.map { String(format: "%02x", $0) }.joined() + } +} \ No newline at end of file diff --git a/bitchat/Utils/BatteryOptimizer.swift b/bitchat/Utils/BatteryOptimizer.swift deleted file mode 100644 index 6f2e887f..00000000 --- a/bitchat/Utils/BatteryOptimizer.swift +++ /dev/null @@ -1,222 +0,0 @@ -// -// BatteryOptimizer.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import Combine -#if os(iOS) -import UIKit -#elseif os(macOS) -import IOKit.ps -#endif - -enum PowerMode { - case performance // Max performance, battery drain OK - case balanced // Default balanced mode - case powerSaver // Aggressive power saving - case ultraLowPower // Emergency mode - - var scanDuration: TimeInterval { - switch self { - case .performance: return 3.0 - case .balanced: return 2.0 - case .powerSaver: return 1.0 - case .ultraLowPower: return 0.5 - } - } - - var scanPauseDuration: TimeInterval { - switch self { - case .performance: return 2.0 - case .balanced: return 3.0 - case .powerSaver: return 8.0 - case .ultraLowPower: return 20.0 - } - } - - var maxConnections: Int { - switch self { - case .performance: return 20 - case .balanced: return 10 - case .powerSaver: return 5 - case .ultraLowPower: return 2 - } - } - - var advertisingInterval: TimeInterval { - // Note: iOS doesn't let us control this directly, but we can stop/start advertising - switch self { - case .performance: return 0.0 // Continuous - case .balanced: return 5.0 // Advertise every 5 seconds - case .powerSaver: return 15.0 // Advertise every 15 seconds - case .ultraLowPower: return 30.0 // Advertise every 30 seconds - } - } -} - -class BatteryOptimizer { - static let shared = BatteryOptimizer() - - @Published var currentPowerMode: PowerMode = .balanced - @Published var isInBackground: Bool = false - @Published var batteryLevel: Float = 1.0 - @Published var isCharging: Bool = false - - private var observers: [NSObjectProtocol] = [] - - private init() { - setupObservers() - updateBatteryStatus() - } - - deinit { - observers.forEach { NotificationCenter.default.removeObserver($0) } - } - - private func setupObservers() { - #if os(iOS) - // Monitor app state - observers.append( - NotificationCenter.default.addObserver( - forName: UIApplication.didEnterBackgroundNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.isInBackground = true - self?.updatePowerMode() - } - ) - - observers.append( - NotificationCenter.default.addObserver( - forName: UIApplication.willEnterForegroundNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.isInBackground = false - self?.updatePowerMode() - } - ) - - // Monitor battery - UIDevice.current.isBatteryMonitoringEnabled = true - - observers.append( - NotificationCenter.default.addObserver( - forName: UIDevice.batteryLevelDidChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.updateBatteryStatus() - } - ) - - observers.append( - NotificationCenter.default.addObserver( - forName: UIDevice.batteryStateDidChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.updateBatteryStatus() - } - ) - #endif - } - - private func updateBatteryStatus() { - #if os(iOS) - batteryLevel = UIDevice.current.batteryLevel - if batteryLevel < 0 { - batteryLevel = 1.0 // Unknown battery level - } - - isCharging = UIDevice.current.batteryState == .charging || - UIDevice.current.batteryState == .full - #elseif os(macOS) - if let info = getMacOSBatteryInfo() { - batteryLevel = info.level - isCharging = info.isCharging - } - #endif - - updatePowerMode() - } - - #if os(macOS) - private func getMacOSBatteryInfo() -> (level: Float, isCharging: Bool)? { - let snapshot = IOPSCopyPowerSourcesInfo().takeRetainedValue() - let sources = IOPSCopyPowerSourcesList(snapshot).takeRetainedValue() as Array - - for source in sources { - if let description = IOPSGetPowerSourceDescription(snapshot, source).takeUnretainedValue() as? [String: Any] { - if let currentCapacity = description[kIOPSCurrentCapacityKey] as? Int, - let maxCapacity = description[kIOPSMaxCapacityKey] as? Int { - let level = Float(currentCapacity) / Float(maxCapacity) - let isCharging = description[kIOPSPowerSourceStateKey] as? String == kIOPSACPowerValue - return (level, isCharging) - } - } - } - return nil - } - #endif - - private func updatePowerMode() { - // Determine optimal power mode based on: - // 1. Battery level - // 2. Charging status - // 3. Background/foreground state - - if isCharging { - // When charging, use performance mode unless battery is critical - currentPowerMode = batteryLevel < 0.1 ? .balanced : .performance - } else if isInBackground { - // In background, be less aggressive with power management - // Don't force ultra-low power mode until battery is below 10% - // Use balanced mode in background above 30% battery - if batteryLevel < 0.1 { - currentPowerMode = .ultraLowPower - } else if batteryLevel < 0.3 { - currentPowerMode = .powerSaver - } else { - currentPowerMode = .balanced // Use balanced mode above 30% in background - } - } else { - // Foreground, not charging - if batteryLevel < 0.1 { - currentPowerMode = .ultraLowPower - } else if batteryLevel < 0.3 { - currentPowerMode = .powerSaver - } else if batteryLevel < 0.6 { - currentPowerMode = .balanced - } else { - currentPowerMode = .performance - } - } - } - - // Manual power mode override - func setPowerMode(_ mode: PowerMode) { - currentPowerMode = mode - } - - // Get current scan parameters - var scanParameters: (duration: TimeInterval, pause: TimeInterval) { - return (currentPowerMode.scanDuration, currentPowerMode.scanPauseDuration) - } - - // Should we skip non-essential operations? - var shouldSkipNonEssential: Bool { - return currentPowerMode == .ultraLowPower || - (currentPowerMode == .powerSaver && isInBackground) - } - - // Should we reduce message frequency? - var shouldThrottleMessages: Bool { - return currentPowerMode == .powerSaver || currentPowerMode == .ultraLowPower - } -} \ No newline at end of file diff --git a/bitchat/Utils/LRUCache.swift b/bitchat/Utils/LRUCache.swift deleted file mode 100644 index d241a625..00000000 --- a/bitchat/Utils/LRUCache.swift +++ /dev/null @@ -1,171 +0,0 @@ -// -// LRUCache.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation - -/// Thread-safe LRU (Least Recently Used) cache implementation -final class LRUCache { - private class Node { - var key: Key - var value: Value - var prev: Node? - var next: Node? - - init(key: Key, value: Value) { - self.key = key - self.value = value - } - } - - private let maxSize: Int - private var cache: [Key: Node] = [:] - private var head: Node? - private var tail: Node? - private let queue = DispatchQueue(label: "bitchat.lrucache", attributes: .concurrent) - - init(maxSize: Int) { - self.maxSize = maxSize - } - - func set(_ key: Key, value: Value) { - queue.sync(flags: .barrier) { - if let node = cache[key] { - // Update existing value and move to front - node.value = value - moveToFront(node) - } else { - // Add new node - let newNode = Node(key: key, value: value) - cache[key] = newNode - addToFront(newNode) - - // Remove oldest if over capacity - if cache.count > maxSize { - if let tailNode = tail { - removeNode(tailNode) - cache.removeValue(forKey: tailNode.key) - } - } - } - } - } - - func get(_ key: Key) -> Value? { - return queue.sync(flags: .barrier) { - guard let node = cache[key] else { return nil } - moveToFront(node) - return node.value - } - } - - func contains(_ key: Key) -> Bool { - return queue.sync { - return cache[key] != nil - } - } - - func remove(_ key: Key) { - queue.sync(flags: .barrier) { - if let node = cache[key] { - removeNode(node) - cache.removeValue(forKey: key) - } - } - } - - func removeAll() { - queue.sync(flags: .barrier) { - cache.removeAll() - head = nil - tail = nil - } - } - - var count: Int { - return queue.sync { - return cache.count - } - } - - var keys: [Key] { - return queue.sync { - return Array(cache.keys) - } - } - - // MARK: - Private Helpers - - private func addToFront(_ node: Node) { - node.next = head - node.prev = nil - - if let head = head { - head.prev = node - } - - head = node - - if tail == nil { - tail = node - } - } - - private func removeNode(_ node: Node) { - if let prev = node.prev { - prev.next = node.next - } else { - head = node.next - } - - if let next = node.next { - next.prev = node.prev - } else { - tail = node.prev - } - - node.prev = nil - node.next = nil - } - - private func moveToFront(_ node: Node) { - guard node !== head else { return } - removeNode(node) - addToFront(node) - } -} - -// MARK: - Bounded Set - -/// Thread-safe set with maximum size using LRU eviction -final class BoundedSet { - private let cache: LRUCache - - init(maxSize: Int) { - self.cache = LRUCache(maxSize: maxSize) - } - - func insert(_ element: Element) { - cache.set(element, value: true) - } - - func contains(_ element: Element) -> Bool { - return cache.get(element) != nil - } - - func remove(_ element: Element) { - cache.remove(element) - } - - func removeAll() { - cache.removeAll() - } - - var count: Int { - return cache.count - } -} \ No newline at end of file diff --git a/bitchat/Utils/OptimizedBloomFilter.swift b/bitchat/Utils/OptimizedBloomFilter.swift deleted file mode 100644 index 0cfebeb8..00000000 --- a/bitchat/Utils/OptimizedBloomFilter.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// OptimizedBloomFilter.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import Foundation -import CryptoKit - -/// Optimized Bloom filter using bit-packed storage and better hash functions -struct OptimizedBloomFilter { - private var bitArray: [UInt64] - private let bitCount: Int - private let hashCount: Int - - // Statistics - private(set) var insertCount: Int = 0 - - init(expectedItems: Int = 1000, falsePositiveRate: Double = 0.01) { - // Calculate optimal bit count and hash count - let m = Double(expectedItems) * abs(log(falsePositiveRate)) / (log(2) * log(2)) - self.bitCount = Int(max(64, m.rounded())) - - let k = Double(bitCount) / Double(expectedItems) * log(2) - self.hashCount = Int(max(1, min(10, k.rounded()))) - - // Initialize bit array (64 bits per UInt64) - let arraySize = (bitCount + 63) / 64 - self.bitArray = Array(repeating: 0, count: arraySize) - } - - mutating func insert(_ item: String) { - let hashes = generateHashes(item) - - for i in 0.. Bool { - let hashes = generateHashes(item) - - for i in 0.. [Int] { - guard let data = item.data(using: .utf8) else { - return Array(repeating: 0, count: hashCount) - } - - // Use SHA256 for high-quality hash values - let hash = SHA256.hash(data: data) - let hashBytes = Array(hash) - - var hashes = [Int]() - - // Extract multiple hash values from the SHA256 output - for i in 0.. 0 else { return 0 } - - // Count set bits - var setBits = 0 - for value in bitArray { - setBits += value.nonzeroBitCount - } - - // Calculate probability: (1 - e^(-kn/m))^k - let ratio = Double(hashCount * insertCount) / Double(bitCount) - return pow(1 - exp(-ratio), Double(hashCount)) - } - - // Get memory usage in bytes - var memorySizeBytes: Int { - return bitArray.count * 8 - } -} - -// Extension for adaptive Bloom filter that adjusts based on network size -extension OptimizedBloomFilter { - static func adaptive(for networkSize: Int) -> OptimizedBloomFilter { - // Adjust parameters based on network size - let expectedItems: Int - let falsePositiveRate: Double - - switch networkSize { - case 0..<50: - expectedItems = 500 - falsePositiveRate = 0.01 - case 50..<200: - expectedItems = 2000 - falsePositiveRate = 0.02 - case 200..<500: - expectedItems = 5000 - falsePositiveRate = 0.03 - default: - expectedItems = 10000 - falsePositiveRate = 0.05 - } - - return OptimizedBloomFilter(expectedItems: expectedItems, falsePositiveRate: falsePositiveRate) - } -} \ No newline at end of file diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 5abed43e..613f33a9 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 BluetoothMeshService +/// - **BitchatDelegate**: Receives messages and events from SimplifiedBluetoothService /// - **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) @@ -95,11 +95,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { @Published var messages: [BitchatMessage] = [] private let maxMessages = 1337 // Maximum messages before oldest are removed - @Published var connectedPeers: [String] = [] - @Published var allPeers: [BitchatPeer] = [] // Unified peer list including favorites - private var peerIndex: [String: BitchatPeer] = [:] // Quick lookup by peer ID - - // MARK: - Properties + @Published var isConnected = false + private var hasNotifiedNetworkAvailable = false + private var recentlySeenPeers: Set = [] + private var lastNetworkNotificationTime = Date.distantPast @Published var nickname: String = "" { didSet { // Trim whitespace whenever nickname is set @@ -107,34 +106,69 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if trimmed != nickname { nickname = trimmed } + // Update mesh service nickname if it's initialized + if meshService.myPeerID != "" { + meshService.setNickname(nickname) + } } } - @Published var isConnected = false - @Published var privateChats: [String: [BitchatMessage]] = [:] // peerID -> messages - @Published var selectedPrivateChatPeer: String? = nil - private var selectedPrivateChatFingerprint: String? = nil // Track by fingerprint for persistence across reconnections - @Published var unreadPrivateMessages: Set = [] + + // MARK: - Service Delegates + + private let commandProcessor: CommandProcessor + private let privateChatManager: PrivateChatManager + private let unifiedPeerService: UnifiedPeerService + private let autocompleteService: AutocompleteService + + // Computed properties for compatibility + @MainActor + var connectedPeers: [String] { Array(unifiedPeerService.connectedPeerIDs) } + @Published var allPeers: [BitchatPeer] = [] + var privateChats: [String: [BitchatMessage]] { + get { privateChatManager.privateChats } + set { privateChatManager.privateChats = newValue } + } + var selectedPrivateChatPeer: String? { + get { privateChatManager.selectedPeer } + set { + if let peer = newValue { + privateChatManager.startChat(with: peer) + } else { + privateChatManager.endChat() + } + } + } + var unreadPrivateMessages: Set { + get { privateChatManager.unreadMessages } + set { privateChatManager.unreadMessages = newValue } + } + + /// Check if there are any unread messages (including from temporary Nostr peer IDs) + var hasAnyUnreadMessages: Bool { + !unreadPrivateMessages.isEmpty + } + + // Missing properties that were removed during refactoring + private var peerIDToPublicKeyFingerprint: [String: String] = [:] + private var selectedPrivateChatFingerprint: String? = nil + private var peerIndex: [String: BitchatPeer] = [:] + + // MARK: - Autocomplete Properties + @Published var autocompleteSuggestions: [String] = [] @Published var showAutocomplete: Bool = false @Published var autocompleteRange: NSRange? = nil @Published var selectedAutocompleteIndex: Int = 0 - // MARK: - Autocomplete Properties - - // Autocomplete optimization - private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: []) - private var cachedNicknames: [String] = [] - private var lastNicknameUpdate: Date = .distantPast - // Temporary property to fix compilation @Published var showPasswordPrompt = false // MARK: - Services and Storage - var meshService = BluetoothMeshService() + var meshService = SimplifiedBluetoothService() private var nostrRelayManager: NostrRelayManager? - private var messageRouter: MessageRouter? - private var peerManager: PeerManager? + // PeerManager replaced by UnifiedPeerService + private var processedNostrEvents = Set() // Simple deduplication private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" @@ -143,11 +177,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Caches for expensive computations private var encryptionStatusCache: [String: EncryptionStatus] = [:] // key: peerID - // MARK: - Social Features + // MARK: - Social Features (Delegated to PeerStateManager) - @Published var favoritePeers: Set = [] // Now stores public key fingerprints instead of peer IDs - private var peerIDToPublicKeyFingerprint: [String: String] = [:] // Maps ephemeral peer IDs to persistent fingerprints - private var blockedUsers: Set = [] // Stores public key fingerprints of blocked users + @MainActor + var favoritePeers: Set { unifiedPeerService.favoritePeers } + @MainActor + var blockedUsers: Set { unifiedPeerService.blockedUsers } // MARK: - Encryption and Security @@ -166,21 +201,77 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Message Delivery Tracking // Delivery tracking - private var deliveryTrackerCancellable: AnyCancellable? private var cancellables = Set() - // Track sent read receipts to avoid duplicates - private var sentReadReceipts: Set = [] // messageID set + // Track sent read receipts to avoid duplicates (persisted across launches) + // Note: Persistence happens automatically in didSet, no lifecycle observers needed + private var sentReadReceipts: Set = [] { // messageID set + didSet { + // Only persist if there are changes + guard oldValue != sentReadReceipts else { return } + + // Persist to UserDefaults whenever it changes + if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) { + UserDefaults.standard.set(data, forKey: "sentReadReceipts") + // Force synchronization for immediate persistence (ensures data is written to disk) + UserDefaults.standard.synchronize() + + // Verify persistence by re-reading + if let verifyData = UserDefaults.standard.data(forKey: "sentReadReceipts"), + let _ = try? JSONDecoder().decode([String].self, from: verifyData) { + // Only log errors, not successful persistence + // Successfully persisted + } else { + SecureLogger.log("⚠️ Failed to verify persistence of read receipts", + category: SecureLogger.session, level: .error) + } + } else { + SecureLogger.log("❌ Failed to encode read receipts for persistence", + category: SecureLogger.session, level: .error) + } + } + } + + // Track processed Nostr ACKs to avoid duplicate processing + private var processedNostrAcks: Set = [] // "messageId:ackType:senderPubkey" format + + // Track app startup phase to prevent marking old messages as unread + private var isStartupPhase = true // Track Nostr pubkey mappings for unknown senders private var nostrKeyMapping: [String: String] = [:] // senderPeerID -> nostrPubkey // MARK: - Initialization + @MainActor init() { + // Load persisted read receipts + if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"), + let receipts = try? JSONDecoder().decode([String].self, from: data) { + self.sentReadReceipts = Set(receipts) + // Successfully loaded read receipts + } else { + // No persisted read receipts found + } + + // Initialize services + self.commandProcessor = CommandProcessor() + self.privateChatManager = PrivateChatManager(meshService: meshService) + self.unifiedPeerService = UnifiedPeerService(meshService: meshService) + self.autocompleteService = AutocompleteService() + + // Wire up dependencies + self.commandProcessor.chatViewModel = self + + // Subscribe to privateChatManager changes to trigger UI updates + privateChatManager.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + self.commandProcessor.meshService = meshService + loadNickname() - loadFavorites() - loadBlockedUsers() loadVerifiedFingerprints() meshService.delegate = self @@ -193,32 +284,42 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } + // Set nickname before starting services + meshService.setNickname(nickname) + // Start mesh service immediately meshService.startServices() - // Set up message retry service - MessageRetryService.shared.meshService = meshService - // Initialize Nostr services Task { @MainActor in nostrRelayManager = NostrRelayManager.shared nostrRelayManager?.connect() - messageRouter = MessageRouter( - meshService: meshService, - nostrRelay: nostrRelayManager! - ) + // Small delay to ensure read receipts are fully loaded + // This prevents race conditions where messages arrive before initialization completes + try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds - // Initialize peer manager - peerManager = PeerManager(meshService: meshService) - peerManager?.updatePeers() + // Set up Nostr message handling directly + setupNostrMessageHandling() - // Bind peer manager's peer list to our published property - let cancellable = peerManager?.$peers + // End startup phase after 2 seconds + // During startup phase, we: + // 1. Skip cleanup of read receipts + // 2. Only block OLD messages from being marked as unread + Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + self.isStartupPhase = false + } + + // Bind unified peer service's peer list to our published property + let cancellable = unifiedPeerService.$peers .receive(on: DispatchQueue.main) .sink { [weak self] peers in + guard let self = self else { return } // Update peers directly - self?.allPeers = peers + self.allPeers = peers + // Force UI update + self.objectWillChange.send() // Update peer index for O(1) lookups // Deduplicate peers by ID to prevent crash from duplicate keys var uniquePeers: [String: BitchatPeer] = [:] @@ -231,21 +332,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate { category: SecureLogger.session, level: .warning) } } - self?.peerIndex = uniquePeers + self.peerIndex = uniquePeers // Schedule UI update if peers changed - if peers.count > 0 || self?.allPeers.count ?? 0 > 0 { + if peers.count > 0 || self.allPeers.count > 0 { // UI will update automatically } // Update private chat peer ID if needed when peers change - if self?.selectedPrivateChatFingerprint != nil { - self?.updatePrivateChatPeerIfNeeded() + if self.selectedPrivateChatFingerprint != nil { + self.updatePrivateChatPeerIfNeeded() } } - if let cancellable = cancellable { - self.cancellables.insert(cancellable) - } + self.cancellables.insert(cancellable) } // Set up Noise encryption callbacks @@ -254,35 +353,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Request notification permission NotificationService.shared.requestAuthorization() - // Subscribe to delivery status updates - deliveryTrackerCancellable = DeliveryTracker.shared.deliveryStatusUpdated - .receive(on: DispatchQueue.main) - .sink { [weak self] (messageID, status) in - self?.updateMessageDeliveryStatus(messageID, status: status) - } - - // Listen for retry notifications - NotificationCenter.default.addObserver( - self, - selector: #selector(handleRetryMessage), - name: Notification.Name("bitchat.retryMessage"), - object: nil - ) - - // Listen for Nostr messages - NotificationCenter.default.addObserver( - self, - selector: #selector(handleNostrMessage), - name: .nostrMessageReceived, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleNostrReadReceipt), - name: .readReceiptReceived, - object: nil - ) // Listen for favorite status changes NotificationCenter.default.addObserver( @@ -301,12 +371,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { ) // Listen for delivery acknowledgments - NotificationCenter.default.addObserver( - self, - selector: #selector(handleDeliveryAcknowledgment), - name: .messageDeliveryAcknowledged, - object: nil - ) // When app becomes active, send read receipts for visible messages #if os(macOS) @@ -404,122 +468,131 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Favorites Management - private func loadFavorites() { - // Load favorites from secure storage - favoritePeers = SecureIdentityStateManager.shared.getFavorites() - } - - private func saveFavorites() { - // Favorites are now saved automatically in SecureIdentityStateManager - // This method is kept for compatibility - } - - // MARK: - Blocked Users Management - - private func loadBlockedUsers() { - // Load blocked users from secure storage - let allIdentities = SecureIdentityStateManager.shared.getAllSocialIdentities() - blockedUsers = Set(allIdentities.filter { $0.isBlocked }.map { $0.fingerprint }) - } - - private func saveBlockedUsers() { - // Blocked users are now saved automatically in SecureIdentityStateManager - // This method is kept for compatibility - } + // MARK: - Blocked Users Management (Delegated to PeerStateManager) - func toggleFavorite(peerID: String) { + /// Check if a peer has unread messages, including messages stored under stable Noise keys and temporary Nostr peer IDs + @MainActor + func hasUnreadMessages(for peerID: String) -> Bool { + // First check direct unread messages + if unreadPrivateMessages.contains(peerID) { + return true + } - Task { @MainActor [weak self] in - guard let self = self else { return } - - // Try to find the favorite relationship first by Noise key, then by Nostr key - var noisePublicKey: Data? - var currentStatus: FavoritesPersistenceService.FavoriteRelationship? - - // First try as Noise key - if let noiseKey = Data(hexString: peerID) { - currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) - noisePublicKey = noiseKey + // Check if messages are stored under the stable Noise key hex + if let peer = unifiedPeerService.getPeer(by: peerID) { + let noiseKeyHex = peer.noisePublicKey.hexEncodedString() + if unreadPrivateMessages.contains(noiseKeyHex) { + return true } - - // getFavoriteStatusByNostrKey not implemented - // If not found, try as Nostr key - // if currentStatus == nil { - // currentStatus = FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(peerID) - // noisePublicKey = currentStatus?.peerNoisePublicKey - // } - - // If still no noise key, we can't proceed - guard let finalNoiseKey = noisePublicKey else { - SecureLogger.log("❌ Could not find noise key for peer: \(peerID)", category: SecureLogger.session, level: .error) - return - } - - let isFavorite = currentStatus?.isFavorite ?? false - - - if isFavorite { - // Remove from favorites - FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: finalNoiseKey) - - // Send unfavorite notification via mesh or Nostr - Task { - try? await self.messageRouter?.sendFavoriteNotification(to: finalNoiseKey, isFavorite: false) + } + + // Get the peer's nickname to check for temporary Nostr peer IDs + let peerNickname = meshService.getPeerNicknames()[peerID]?.lowercased() ?? "" + + // Check if any temporary Nostr peer IDs have unread messages from this nickname + for unreadPeerID in unreadPrivateMessages { + if unreadPeerID.hasPrefix("nostr_") { + // Check if messages from this temporary peer match the nickname + if let messages = privateChats[unreadPeerID], + let firstMessage = messages.first, + firstMessage.sender.lowercased() == peerNickname { + return true } + } + } + + return false + } + + @MainActor + func toggleFavorite(peerID: String) { + // Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars) + // Ephemeral peer IDs are 8 bytes = 16 hex characters + // Noise public keys are 32 bytes = 64 hex characters + + if peerID.count == 64, let noisePublicKey = Data(hexString: peerID) { + // This is a stable Noise key hex (used in private chats) + // Find the ephemeral peer ID for this Noise key + let ephemeralPeerID = unifiedPeerService.peers.first { peer in + peer.noisePublicKey == noisePublicKey + }?.id + + if let ephemeralID = ephemeralPeerID { + // Found the ephemeral peer, use normal toggle + unifiedPeerService.toggleFavorite(ephemeralID) + // Also trigger UI update + objectWillChange.send() } else { - // Add to favorites - let peers = self.allPeers - let nickname = self.meshService.getPeerNicknames()[peerID] ?? peers.first(where: { $0.id == peerID })?.displayName ?? "Unknown" - let nostrPublicKey = peers.first(where: { $0.id == peerID })?.nostrPublicKey + // No ephemeral peer found, directly toggle via FavoritesPersistenceService + let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) + let wasFavorite = currentStatus?.isFavorite ?? false - // Ensure we have a Nostr identity created - if (try? NostrIdentityBridge.getCurrentNostrIdentity()) != nil { + if wasFavorite { + // Remove favorite + FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) + } else { + // Add favorite - get nickname from current status or from private chat messages + var nickname = currentStatus?.peerNickname + + // If no nickname in status, try to get from private chat messages + if nickname == nil, let messages = privateChats[peerID], !messages.isEmpty { + // Get the nickname from the first message where this peer was the sender + nickname = messages.first { $0.senderPeerID == peerID }?.sender + } + + let finalNickname = nickname ?? "Unknown" + let nostrKey = currentStatus?.peerNostrPublicKey ?? NostrIdentityBridge.getNostrPublicKey(for: noisePublicKey) + + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: noisePublicKey, + peerNostrPublicKey: nostrKey, + peerNickname: finalNickname + ) } - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: finalNoiseKey, - peerNostrPublicKey: nostrPublicKey ?? currentStatus?.peerNostrPublicKey, - peerNickname: nickname - ) + // Trigger UI update + objectWillChange.send() - // Send favorite notification via mesh or Nostr - Task { - try? await self.messageRouter?.sendFavoriteNotification(to: finalNoiseKey, isFavorite: true) + // Send favorite notification via Nostr if we're mutual favorites + if !wasFavorite && currentStatus?.theyFavoritedUs == true { + // We just favorited them and they already favorite us - send via Nostr + sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: true) + } else if wasFavorite { + // We're unfavoriting - send via Nostr if they still favorite us + sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: false) } } - - // Update the peer list to reflect the change - self.peerManager?.updatePeers() - - // Check if we now have a mutual favorite relationship - if let updatedStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey), - updatedStatus.isMutual { - } + } else { + // This is an ephemeral peer ID (16 hex chars), use normal toggle + unifiedPeerService.toggleFavorite(peerID) + // Trigger UI update + objectWillChange.send() } } @MainActor func isFavorite(peerID: String) -> Bool { - // First try as Noise public key - if let noisePublicKey = Data(hexString: peerID) { + // Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars) + if peerID.count == 64, let noisePublicKey = Data(hexString: peerID) { + // This is a Noise public key if let status = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) { return status.isFavorite } + } else { + // This is an ephemeral peer ID - check with UnifiedPeerService + if let peer = unifiedPeerService.getPeer(by: peerID) { + return peer.isFavorite + } } - // getFavoriteStatusByNostrKey not implemented - // If not found, try as Nostr public key - // if let status = FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(peerID) { - // return status.isFavorite - // } - return false } // MARK: - Public Key and Identity Management // Called when we receive a peer's public key + @MainActor func registerPeerPublicKey(peerID: String, publicKeyData: Data) { // Create a fingerprint from the public key (full SHA256, not truncated) let fingerprintStr = SHA256.hash(data: publicKeyData) @@ -572,21 +645,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } - private func isPeerBlocked(_ peerID: String) -> Bool { - // Check if we have the public key fingerprint for this peer - if let fingerprint = peerIDToPublicKeyFingerprint[peerID] { - return SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) - } - - // Try to get fingerprint from mesh service - if let fingerprint = meshService.getPeerFingerprint(peerID) { - return SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) - } - - return false + @MainActor + func isPeerBlocked(_ peerID: String) -> Bool { + return unifiedPeerService.isBlocked(peerID) } // Helper method to find current peer ID for a fingerprint + @MainActor private func getCurrentPeerIDForFingerprint(_ fingerprint: String) -> String? { // Search through all connected peers to find the one with matching fingerprint for peerID in connectedPeers { @@ -599,6 +664,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } // Helper method to update selectedPrivateChatPeer if fingerprint matches + @MainActor private func updatePrivateChatPeerIfNeeded() { guard let chatFingerprint = selectedPrivateChatFingerprint else { return } @@ -609,23 +675,30 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Migrate messages from old peer ID to new peer ID if let oldMessages = privateChats[oldPeerID] { - if privateChats[currentPeerID] == nil { - privateChats[currentPeerID] = [] + var chats = privateChats + if chats[currentPeerID] == nil { + chats[currentPeerID] = [] } - privateChats[currentPeerID]?.append(contentsOf: oldMessages) + chats[currentPeerID]?.append(contentsOf: oldMessages) // Sort by timestamp - privateChats[currentPeerID]?.sort { $0.timestamp < $1.timestamp } + chats[currentPeerID]?.sort { $0.timestamp < $1.timestamp } + // Remove duplicates var seen = Set() - privateChats[currentPeerID] = privateChats[currentPeerID]?.filter { msg in + chats[currentPeerID] = chats[currentPeerID]?.filter { msg in if seen.contains(msg.id) { return false } seen.insert(msg.id) return true } + + // Remove old peer ID + chats.removeValue(forKey: oldPeerID) + + // Update all at once + privateChats = chats // Trigger setter trimPrivateChatMessagesIfNeeded(for: currentPeerID) - privateChats.removeValue(forKey: oldPeerID) } // Migrate unread status @@ -641,7 +714,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Also refresh the peer list to update encryption status Task { @MainActor in - peerManager?.updatePeers() + // UnifiedPeerService updates automatically via subscriptions } } else if selectedPrivateChatPeer == nil { // Just set the peer ID if we don't have one @@ -719,34 +792,33 @@ class ChatViewModel: ObservableObject, BitchatDelegate { func sendPrivateMessage(_ content: String, to peerID: String) { guard !content.isEmpty else { return } - // Check if peer is available on mesh (actually connected, not just known) - let meshPeers = meshService.getPeerNicknames() - let peerAvailableOnMesh = meshService.isPeerConnected(peerID) - let recipientNickname: String - - if let meshNickname = meshPeers[peerID] { - recipientNickname = meshNickname - } else { - // Try to get from favorites - guard let noiseKey = Data(hexString: peerID), - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) else { - return - } - recipientNickname = favoriteStatus.peerNickname - } - - // Check if the recipient is blocked - if isPeerBlocked(peerID) { - addSystemMessage("cannot send message to \(recipientNickname): user is blocked.") + // Check if blocked + if unifiedPeerService.isBlocked(peerID) { + let nickname = meshService.getPeerNicknames()[peerID] ?? "user" + addSystemMessage("cannot send message to \(nickname): user is blocked.") return } - // IMPORTANT: When sending a message, it means we're viewing this chat - // Send read receipts for any delivered messages from this peer - markPrivateMessagesAsRead(from: peerID) + // Determine routing method and recipient nickname + guard let noiseKey = Data(hexString: peerID) else { return } + let isConnected = meshService.isPeerConnected(peerID) + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) + let isMutualFavorite = favoriteStatus?.isMutual ?? false + let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil - // Create the message locally + // Get nickname from various sources + var recipientNickname = meshService.getPeerNicknames()[peerID] + if recipientNickname == nil && favoriteStatus != nil { + recipientNickname = favoriteStatus?.peerNickname + } + recipientNickname = recipientNickname ?? "user" + + // Generate message ID + let messageID = UUID().uuidString + + // Create the message object let message = BitchatMessage( + id: messageID, sender: nickname, content: content, timestamp: Date(), @@ -755,49 +827,34 @@ class ChatViewModel: ObservableObject, BitchatDelegate { isPrivate: true, recipientNickname: recipientNickname, senderPeerID: meshService.myPeerID, + mentions: nil, deliveryStatus: .sending ) - // Add to our private chat history + // Add to local chat if privateChats[peerID] == nil { privateChats[peerID] = [] } privateChats[peerID]?.append(message) trimPrivateChatMessagesIfNeeded(for: peerID) - // Track the message for delivery confirmation - let isFavorite = isFavorite(peerID: peerID) - DeliveryTracker.shared.trackMessage(message, recipientID: peerID, recipientNickname: recipientNickname, isFavorite: isFavorite) - - // Immediate UI update for user's own messages + // Trigger UI update for sent message objectWillChange.send() - // Determine how to send the message - guard let noiseKey = Data(hexString: peerID) else { return } - - if peerAvailableOnMesh { - // Send via mesh with the same message ID - meshService.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: message.id) - } else if let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - favoriteStatus.isMutual { + // 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 - - Task { - do { - try await messageRouter?.sendMessage(content, to: noiseKey, messageId: message.id) - } catch { - SecureLogger.log("Failed to send message via Nostr: \(error)", - category: SecureLogger.session, level: .error) - // DeliveryTracker will handle timeout automatically - } - } + sendViaNostr(content, to: recipientNostrPubkey, messageId: messageID) } else { - // Not reachable - show error to user - SecureLogger.log("⚠️ Cannot send message to \(recipientNickname) - not available on mesh or Nostr", - category: SecureLogger.session, level: .warning) - - // Add system message to inform user - addSystemMessage("Cannot send message to \(recipientNickname) - peer is not reachable via mesh or Nostr.") + // Update delivery status to failed + if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[index].deliveryStatus = .failed(reason: "Peer not reachable") + } + addSystemMessage("Cannot send message to \(recipientNickname ?? "user") - peer is not reachable via mesh or Nostr.") } } @@ -840,126 +897,209 @@ class ChatViewModel: ObservableObject, BitchatDelegate { func startPrivateChat(with peerID: String) { // Safety check: Don't allow starting chat with ourselves if peerID == meshService.myPeerID { - SecureLogger.log("⚠️ Attempted to start private chat with self, ignoring", - category: SecureLogger.session, level: .warning) return } - // Try to get nickname from mesh first, then from peer list - let peerNickname = meshService.getPeerNicknames()[peerID] ?? - peerIndex[peerID]?.displayName ?? - "unknown" + let peerNickname = meshService.getPeerNicknames()[peerID] ?? "unknown" // Check if the peer is blocked - if isPeerBlocked(peerID) { + if unifiedPeerService.isBlocked(peerID) { addSystemMessage("cannot start chat with \(peerNickname): user is blocked.") return } - // Check if this is a moon peer (we favorite them but they don't favorite us) AND they're offline - // Only require mutual favorites for offline Nostr messaging - if let peer = peerIndex[peerID], + // Check mutual favorites for offline messaging + if let peer = unifiedPeerService.getPeer(by: peerID), peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected { addSystemMessage("cannot start chat with \(peerNickname): mutual favorite required for offline messaging.") return } - // Trigger handshake if we don't have a session yet + // Consolidate messages from stable Noise key if needed + // This ensures Nostr messages appear when opening a chat with an ephemeral peer ID + if let peer = unifiedPeerService.getPeer(by: peerID) { + let noiseKeyHex = peer.noisePublicKey.hexEncodedString() + + // If we have messages stored under the stable Noise key hex but not under the ephemeral ID, + // or if we need to merge them, do so now + if noiseKeyHex != peerID { + if let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty { + // Check if there are ACTUALLY unread messages (not just the unread flag) + // Only transfer unread status if there are recent unread messages + var hasActualUnreadMessages = false + + // Merge messages from stable key into ephemeral peer ID storage + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } + + // Add any messages that aren't already in the ephemeral storage + let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? []) + for message in nostrMessages { + if !existingMessageIds.contains(message.id) { + // Create updated message with correct senderPeerID + // This is crucial for read receipts to work correctly + let updatedMessage = BitchatMessage( + id: message.id, + sender: message.sender, + content: message.content, + timestamp: message.timestamp, + isRelay: message.isRelay, + originalSender: message.originalSender, + isPrivate: message.isPrivate, + recipientNickname: message.recipientNickname, + senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID, // Update peer ID if it's from them + mentions: message.mentions, + deliveryStatus: message.deliveryStatus + ) + privateChats[peerID]?.append(updatedMessage) + + // Check if this is an actually unread message + // Only mark as unread if: + // 1. Not a message we sent + // 2. Message is recent (< 60s old) + // Never mark old messages as unread during consolidation + if message.senderPeerID != meshService.myPeerID { + let messageAge = Date().timeIntervalSince(message.timestamp) + if messageAge < 60 && !sentReadReceipts.contains(message.id) { + hasActualUnreadMessages = true + } + } + } + } + + // Sort by timestamp + privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } + + // Only transfer unread status if there are actual recent unread messages + if hasActualUnreadMessages { + unreadPrivateMessages.insert(peerID) + } else if unreadPrivateMessages.contains(noiseKeyHex) { + // Remove incorrect unread status from stable key + unreadPrivateMessages.remove(noiseKeyHex) + } + + // Clean up the stable key storage to avoid duplication + privateChats.removeValue(forKey: noiseKeyHex) + + // Consolidated Nostr messages from stable key + } + } + } + + // Also consolidate messages from temporary Nostr peer IDs + // These are messages received via Nostr when we didn't know the sender's Noise key + // They're stored under "nostr_" + first 16 chars of Nostr pubkey + let currentPeerNickname = peerNickname.lowercased() + var tempPeerIDsToConsolidate: [String] = [] + + // Find all temporary Nostr peer IDs that have messages from the same nickname + for (storedPeerID, messages) in privateChats { + if storedPeerID.hasPrefix("nostr_") && storedPeerID != peerID { + // Check if ALL messages from this temporary peer have the same sender nickname + // This is more reliable than just checking the first message + let nicknamesMatch = messages.allSatisfy { msg in + msg.sender.lowercased() == currentPeerNickname + } + if nicknamesMatch && !messages.isEmpty { + tempPeerIDsToConsolidate.append(storedPeerID) + } + } + } + + // Consolidate messages from temporary Nostr peer IDs + if !tempPeerIDsToConsolidate.isEmpty { + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } + + let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? []) + var consolidatedCount = 0 + + var hadUnreadTemp = false + for tempPeerID in tempPeerIDsToConsolidate { + // Check if this temp peer ID had unread messages + if unreadPrivateMessages.contains(tempPeerID) { + hadUnreadTemp = true + } + + if let tempMessages = privateChats[tempPeerID] { + for message in tempMessages { + if !existingMessageIds.contains(message.id) { + // Create a new message with the updated sender peer ID + let updatedMessage = BitchatMessage( + id: message.id, + sender: message.sender, + content: message.content, + timestamp: message.timestamp, + isRelay: message.isRelay, + originalSender: message.originalSender, + isPrivate: message.isPrivate, + recipientNickname: message.recipientNickname, + senderPeerID: peerID, // Update to match current peer + mentions: message.mentions, + deliveryStatus: message.deliveryStatus + ) + privateChats[peerID]?.append(updatedMessage) + consolidatedCount += 1 + } + } + // Remove the temporary storage + privateChats.removeValue(forKey: tempPeerID) + unreadPrivateMessages.remove(tempPeerID) + } + } + + // If any temp peer ID had unread messages, mark the consolidated peer as unread + if hadUnreadTemp { + unreadPrivateMessages.insert(peerID) + SecureLogger.log("📬 Transferred unread status from temp peer IDs to \(peerID)", + category: SecureLogger.session, level: .debug) + } + + if consolidatedCount > 0 { + // Sort by timestamp + privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } + + SecureLogger.log("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", + category: SecureLogger.session, level: .info) + } + } + + // Trigger handshake if needed let sessionState = meshService.getNoiseSessionState(for: peerID) switch sessionState { case .none, .failed: - // Initiate handshake when opening PM meshService.triggerHandshake(with: peerID) default: break } - selectedPrivateChatPeer = peerID - // Also track by fingerprint for persistence across reconnections - selectedPrivateChatFingerprint = peerIDToPublicKeyFingerprint[peerID] - unreadPrivateMessages.remove(peerID) - - // Check if we need to migrate messages from an old peer ID - // This happens when peer IDs change between sessions - if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true { - - // Get the fingerprint for this peer - let currentFingerprint = getFingerprint(for: peerID) - - // Look for messages under other peer IDs with the same fingerprint - var migratedMessages: [BitchatMessage] = [] - var oldPeerIDsToRemove: [String] = [] - - for (oldPeerID, messages) in privateChats { - if oldPeerID != peerID { - // Check if this old peer ID has the same fingerprint (same actual peer) - let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID] - - if let currentFp = currentFingerprint, - let oldFp = oldFingerprint, - currentFp == oldFp { - // Same peer with different ID - migrate all messages - migratedMessages.append(contentsOf: messages) - oldPeerIDsToRemove.append(oldPeerID) - - } else if currentFingerprint == nil || oldFingerprint == nil { - // Fallback: use nickname matching only if we don't have fingerprints - // This is less reliable but handles legacy data - let messagesWithPeer = messages.filter { msg in - // Message is FROM the peer to us - (msg.sender == peerNickname && msg.sender != nickname) || - // OR message is FROM us TO the peer - (msg.sender == nickname && (msg.recipientNickname == peerNickname || - // Also check if this was a private message in a chat that only has us and one other person - (msg.isPrivate && messages.allSatisfy { m in - m.sender == nickname || m.sender == peerNickname - }))) - } - - if !messagesWithPeer.isEmpty { - // Check if ALL messages in this chat are between us and this peer - let allMessagesAreWithPeer = messages.allSatisfy { msg in - (msg.sender == peerNickname || msg.sender == nickname) && - (msg.recipientNickname == nil || msg.recipientNickname == peerNickname || msg.recipientNickname == nickname) - } - - if allMessagesAreWithPeer { - // This entire chat history likely belongs to this peer, migrate it all - migratedMessages.append(contentsOf: messages) - oldPeerIDsToRemove.append(oldPeerID) - - SecureLogger.log("📦 Migrating \(messages.count) messages from old peer ID \(oldPeerID) to \(peerID) based on nickname match (no fingerprints available)", - category: SecureLogger.session, level: .warning) - } + // Delegate to private chat manager but add already-acked messages first + // This prevents duplicate read receipts + // IMPORTANT: Only add messages WE sent to sentReadReceipts, not messages we received + if let messages = privateChats[peerID] { + for message in messages { + // Only track read receipts for messages WE sent (not received messages) + if message.sender == nickname { + // Check if message has been read or delivered + if let status = message.deliveryStatus { + switch status { + case .read, .delivered: + sentReadReceipts.insert(message.id) + privateChatManager.sentReadReceipts.insert(message.id) + default: + break } } } } - - // Remove old peer ID entries that were fully migrated - for oldPeerID in oldPeerIDsToRemove { - privateChats.removeValue(forKey: oldPeerID) - unreadPrivateMessages.remove(oldPeerID) - } - - // Initialize chat history with migrated messages if any - if !migratedMessages.isEmpty { - privateChats[peerID] = migratedMessages.sorted { $0.timestamp < $1.timestamp } - trimPrivateChatMessagesIfNeeded(for: peerID) - } else { - privateChats[peerID] = [] - } } - _ = privateChats[peerID] ?? [] + privateChatManager.startChat(with: peerID) - // Send read receipts for unread messages from this peer - // Add a small delay to ensure UI has updated - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - self?.markPrivateMessagesAsRead(from: peerID) - } - - // Also try immediately in case messages are already there + // Also mark messages as read for Nostr ACKs + // This ensures read receipts are sent even for consolidated messages markPrivateMessagesAsRead(from: peerID) } @@ -968,34 +1108,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { selectedPrivateChatFingerprint = nil } - // MARK: - Message Retry Handling - - @objc private func handleRetryMessage(_ notification: Notification) { - guard let messageID = notification.userInfo?["messageID"] as? String else { return } - - // Find the message to retry - if let message = messages.first(where: { $0.id == messageID }) { - SecureLogger.log("Retrying message \(messageID) to \(message.recipientNickname ?? "unknown")", - category: SecureLogger.session, level: .info) - - // Resend the message through mesh service - if message.isPrivate, - let peerID = getPeerIDForNickname(message.recipientNickname ?? "") { - // Update status to sending - updateMessageDeliveryStatus(messageID, status: .sending) - - // Resend via mesh service - meshService.sendMessage(message.content, - mentions: message.mentions ?? [], - to: peerID, - messageID: messageID, - timestamp: message.timestamp) - } - } - } - // MARK: - Nostr Message Handling + @MainActor @objc private func handleNostrMessage(_ notification: Notification) { guard let message = notification.userInfo?["message"] as? BitchatMessage else { return } @@ -1044,6 +1159,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { didReceiveReadReceipt(receipt) } + @MainActor @objc private func handlePeerStatusUpdate(_ notification: Notification) { // Update private chat peer if needed when peer status changes updatePrivateChatPeerIfNeeded() @@ -1067,8 +1183,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Transfer private chat messages to new peer ID if let messages = privateChats[oldPeerID] { - privateChats[newPeerID] = messages - privateChats.removeValue(forKey: oldPeerID) + var chats = privateChats + chats[newPeerID] = messages + chats.removeValue(forKey: oldPeerID) + privateChats = chats // Trigger setter } // Transfer unread status @@ -1094,8 +1212,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if let messages = privateChats[oldPeerID] { SecureLogger.log("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: SecureLogger.session, level: .debug) - privateChats[newPeerID] = messages - privateChats.removeValue(forKey: oldPeerID) + var chats = privateChats + chats[newPeerID] = messages + chats.removeValue(forKey: oldPeerID) + privateChats = chats // Trigger setter } // Transfer unread status @@ -1148,7 +1268,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { addMessage(systemMessage) // Update peer manager to refresh UI - peerManager?.updatePeers() + // UnifiedPeerService updates automatically via subscriptions } } } @@ -1181,7 +1301,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) + meshService.sendPrivateMessage(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) @@ -1199,10 +1319,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { recipientNickname: meshService.getPeerNicknames()[peerID], senderPeerID: meshService.myPeerID ) - if privateChats[peerID] == nil { - privateChats[peerID] = [] + var chats = privateChats + if chats[peerID] == nil { + chats[peerID] = [] } - privateChats[peerID]?.append(localNotification) + chats[peerID]?.append(localNotification) + privateChats = chats // Trigger setter trimPrivateChatMessagesIfNeeded(for: peerID) } else { @@ -1226,6 +1348,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } @objc func applicationWillTerminate() { + // Send leave message to all peers + meshService.stopServices() // Force save any pending identity changes (verifications, favorites, etc) SecureIdentityStateManager.shared.forceSave() @@ -1270,33 +1394,55 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // If we know the original transport, use it for the read receipt if originalTransport == "nostr" { - // Message was received via Nostr, send read receipt via Nostr - if let noiseKey = Data(hexString: actualPeerID) { - Task { @MainActor in - try? await messageRouter?.sendReadReceipt(for: receipt.originalMessageID, to: noiseKey, preferredTransport: .nostr) - } - } else if let nostrPubkey = nostrKeyMapping[actualPeerID] { - // This is a Nostr-only peer we haven't mapped to a Noise key yet - Task { @MainActor in - if let tempNoiseKey = Data(hexString: nostrPubkey) { - try? await messageRouter?.sendReadReceipt(for: receipt.originalMessageID, to: tempNoiseKey, preferredTransport: .nostr) - } - } - } + // Skip read receipts for Nostr messages - unnecessary complexity + // 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) } else { - // Try Nostr for offline peers - if let noiseKey = Data(hexString: actualPeerID) { - Task { @MainActor in - try? await messageRouter?.sendReadReceipt(for: receipt.originalMessageID, to: noiseKey) - } - } else if let nostrPubkey = nostrKeyMapping[actualPeerID] { - // This is a Nostr-only peer we haven't mapped to a Noise key yet - Task { @MainActor in - if let tempNoiseKey = Data(hexString: nostrPubkey) { - try? await messageRouter?.sendReadReceipt(for: receipt.originalMessageID, to: tempNoiseKey) + // Skip read receipts for offline peers - fire and forget principle + } + } + + @MainActor + func markPrivateMessagesAsRead(from peerID: String) { + privateChatManager.markAsRead(from: peerID) + + // Get the peer's Noise key to check for Nostr messages + var noiseKeyHex: String? = nil + var peerNostrPubkey: String? = nil + + // First check if peerID is already a hex Noise key + if let noiseKey = Data(hexString: peerID), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { + noiseKeyHex = peerID + peerNostrPubkey = favoriteStatus.peerNostrPublicKey + } + // Otherwise get the Noise key from the peer info + else if let peer = unifiedPeerService.getPeer(by: peerID) { + noiseKeyHex = peer.noisePublicKey.hexEncodedString() + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey) + peerNostrPubkey = favoriteStatus?.peerNostrPublicKey + + // Also remove unread status from the stable Noise key if it exists + if let keyHex = noiseKeyHex, unreadPrivateMessages.contains(keyHex) { + unreadPrivateMessages.remove(keyHex) + } + } + + // Send Nostr read ACKs if peer has Nostr capability + if let nostrPubkey = peerNostrPubkey { + // Check messages under both ephemeral peer ID and stable Noise key + let messagesToAck = getPrivateChatMessages(for: peerID) + + for message in messagesToAck { + // Only send read ACKs for messages from the peer (not our own) + // Check both the ephemeral peer ID and stable Noise key as sender + if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay { + // Skip if we already sent an ACK for this message + if !sentReadReceipts.contains(message.id) { + sendNostrAcknowledgment(messageId: message.id, to: nostrPubkey, type: "READ") + sentReadReceipts.insert(message.id) } } } @@ -1304,161 +1450,33 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } @MainActor - func markPrivateMessagesAsRead(from peerID: String) { - // Get the nickname for this peer - let peerNickname = meshService.getPeerNicknames()[peerID] ?? "" - - // First ensure we have the latest messages (in case of migration) - if let messages = privateChats[peerID], !messages.isEmpty { - } else { - - // Look through ALL private chats to find messages from this nickname - for (_, chatMessages) in privateChats { - let relevantMessages = chatMessages.filter { msg in - msg.sender == peerNickname && msg.sender != nickname - } - if !relevantMessages.isEmpty { - } - } - } - - guard let messages = privateChats[peerID], !messages.isEmpty else { - return - } - - - // Find messages from the peer that haven't been read yet - var readReceiptsSent = 0 - for (_, message) in messages.enumerated() { - // Only send read receipts for messages from the other peer (not our own) - // Check multiple conditions to ensure we catch all messages from the peer - let isOurMessage = message.sender == nickname - let isFromPeerByNickname = !peerNickname.isEmpty && message.sender == peerNickname - - // Check if message is from this peer by comparing IDs - // Handle both Noise and Nostr keys - var isFromPeerByID = false - if let msgSenderID = message.senderPeerID { - // Direct match - isFromPeerByID = msgSenderID == peerID - - // If no direct match, check if we're comparing Nostr keys - if !isFromPeerByID { - // Check if the peerID has a favorite entry with a Nostr key that matches - if let noiseKey = Data(hexString: peerID), - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - let nostrKey = favoriteStatus.peerNostrPublicKey { - isFromPeerByID = msgSenderID == nostrKey - } - - // getFavoriteStatusByNostrKey not implemented - // Or check if the message sender ID maps to this peer's Noise key - // if !isFromPeerByID, - // let senderFavorite = FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(msgSenderID), - // senderFavorite.peerNoisePublicKey.hexEncodedString() == peerID { - // isFromPeerByID = true - // } - } - } - - let isPrivateToUs = message.isPrivate && message.recipientNickname == nickname - - // This is a message FROM the peer if it's not from us AND (matches nickname OR peer ID OR is private to us) - let isFromPeer = !isOurMessage && (isFromPeerByNickname || isFromPeerByID || isPrivateToUs) - - if message.id == message.id { // Always true, for debugging - } - - if isFromPeer { - if let status = message.deliveryStatus { - switch status { - case .sent, .delivered: - // Create and send read receipt for sent or delivered messages - // Check if we've already sent a receipt for this message - if !sentReadReceipts.contains(message.id) { - let receipt = ReadReceipt( - originalMessageID: message.id, - readerID: meshService.myPeerID, - readerNickname: nickname - ) - - // Send read receipt to the message sender - // Use the message's senderPeerID if available (for Nostr messages) - // Otherwise use the current peerID - let recipientID = message.senderPeerID ?? peerID - - // Check if message was delivered via Nostr - var originalTransport: String? = nil - if let status = message.deliveryStatus { - switch status { - case .delivered(let transport, _): - originalTransport = transport - default: - break - } - } - - sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport) - sentReadReceipts.insert(message.id) - readReceiptsSent += 1 - } else { - } - case .read: - // Already read, no need to send another receipt - break - default: - // Message not yet delivered, can't mark as read - break - } - } else { - // No delivery status - this might be an older message - // Send read receipt anyway for backwards compatibility - if !sentReadReceipts.contains(message.id) { - let receipt = ReadReceipt( - originalMessageID: message.id, - readerID: meshService.myPeerID, - readerNickname: nickname - ) - // Use the message's senderPeerID if available (for Nostr messages) - let recipientID = message.senderPeerID ?? peerID - - // For backwards compatibility, check if this might be a Nostr message - // by checking if the sender has a Nostr key - let receiptToSend = receipt - let recipientToSend = recipientID - Task { @MainActor in - var originalTransport: String? = nil - if let noiseKey = Data(hexString: recipientToSend), - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - favoriteStatus.peerNostrPublicKey != nil, - self.meshService.getPeerNicknames()[recipientToSend] == nil { - // Peer has Nostr key and is not on mesh, likely received via Nostr - originalTransport = "nostr" - } - - self.sendReadReceipt(receiptToSend, to: recipientToSend, originalTransport: originalTransport) - } - sentReadReceipts.insert(message.id) - readReceiptsSent += 1 - } else { - } - } - } else { - } - } - - } - func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] { - let messages = privateChats[peerID] ?? [] - if !messages.isEmpty { + var allMessages: [BitchatMessage] = [] + + // Get messages stored under the ephemeral peer ID + if let ephemeralMessages = privateChats[peerID] { + allMessages.append(contentsOf: ephemeralMessages) } - return messages + + // Also check if we have messages stored under the stable Noise key hex + // This happens for Nostr messages which use stable keys to persist across reconnections + if let peer = unifiedPeerService.getPeer(by: peerID) { + let noiseKeyHex = peer.noisePublicKey.hexEncodedString() + + // Only add if it's different from the ephemeral peer ID (to avoid duplicates) + if noiseKeyHex != peerID, + let nostrMessages = privateChats[noiseKeyHex] { + allMessages.append(contentsOf: nostrMessages) + } + } + + // Sort by timestamp to maintain chronological order + return allMessages.sorted { $0.timestamp < $1.timestamp } } + @MainActor func getPeerIDForNickname(_ nickname: String) -> String? { - let nicknames = meshService.getPeerNicknames() - return nicknames.first(where: { $0.value == nickname })?.key + return unifiedPeerService.getPeerID(for: nickname) } @@ -1471,13 +1489,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Clear all messages messages.removeAll() - privateChats.removeAll() - unreadPrivateMessages.removeAll() + privateChatManager.privateChats.removeAll() + privateChatManager.unreadMessages.removeAll() - // Delete all keychain data + // Delete all keychain data (including Noise and Nostr keys) _ = KeychainManager.shared.deleteAllKeychainData() - // Clear UserDefaults identity fallbacks + // Clear UserDefaults identity data userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey") userDefaults.removeObject(forKey: "bitchat.messageRetentionKey") @@ -1485,16 +1503,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { verifiedFingerprints.removeAll() // Verified fingerprints are cleared when identity data is cleared below - // Clear message retry queue - MessageRetryService.shared.clearRetryQueue() - - // Reset nickname to anonymous nickname = "anon\(Int.random(in: 1000...9999))" saveNickname() - // Clear favorites - favoritePeers.removeAll() + // Clear favorites and peer mappings + // Clear through SecureIdentityStateManager instead of directly + SecureIdentityStateManager.shared.clearAllIdentityData() peerIDToPublicKeyFingerprint.removeAll() // Clear persistent favorites from keychain @@ -1515,10 +1530,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Clear read receipt tracking sentReadReceipts.removeAll() + processedNostrAcks.removeAll() // Clear all caches invalidateEncryptionCache() + // IMPORTANT: Clear Nostr-related state + // Disconnect from Nostr relays and clear subscriptions + nostrRelayManager?.disconnect() + nostrRelayManager = nil + + // Clear Nostr identity associations + NostrIdentityBridge.clearAllAssociations() + // Disconnect from all peers and clear persistent identity // This will force creation of a new identity (new fingerprint) on next launch meshService.emergencyDisconnectAll() @@ -1526,6 +1550,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Force immediate UserDefaults synchronization userDefaults.synchronize() + // Reinitialize Nostr with new identity + // This will generate new Nostr keys derived from new Noise keys + Task { @MainActor in + // Small delay to ensure cleanup completes + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + // Reinitialize Nostr relay manager with new identity + nostrRelayManager = NostrRelayManager() + setupNostrMessageHandling() + nostrRelayManager?.connect() + } + // Force immediate UI update for panic mode // UI updates immediately - no flushing needed @@ -1545,86 +1581,32 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Autocomplete func updateAutocomplete(for text: String, cursorPosition: Int) { - // Quick early exit for empty text - guard cursorPosition > 0 else { - if showAutocomplete { - showAutocomplete = false - autocompleteSuggestions = [] - autocompleteRange = nil - } - return - } + // Get peer nicknames but exclude self + let allPeers = meshService.getPeerNicknames().values + let peers = allPeers.filter { $0 != meshService.myNickname } + let (suggestions, range) = autocompleteService.getSuggestions( + for: text, + peers: Array(peers), + cursorPosition: cursorPosition + ) - // Find @ symbol before cursor - let beforeCursor = String(text.prefix(cursorPosition)) - - // Use cached regex - guard let regex = mentionRegex, - let match = regex.firstMatch(in: beforeCursor, options: [], range: NSRange(location: 0, length: beforeCursor.count)) else { - if showAutocomplete { - showAutocomplete = false - autocompleteSuggestions = [] - autocompleteRange = nil - } - return - } - - // Extract the partial nickname - let partialRange = match.range(at: 1) - guard let range = Range(partialRange, in: beforeCursor) else { - if showAutocomplete { - showAutocomplete = false - autocompleteSuggestions = [] - autocompleteRange = nil - } - return - } - - let partial = String(beforeCursor[range]).lowercased() - - // Update cached nicknames only if peer list changed (check every 1 second max) - let now = Date() - if now.timeIntervalSince(lastNicknameUpdate) > 1.0 || cachedNicknames.isEmpty { - let peerNicknames = meshService.getPeerNicknames() - cachedNicknames = Array(peerNicknames.values).sorted() - lastNicknameUpdate = now - } - - // Filter suggestions using cached nicknames - let suggestions = cachedNicknames.filter { nick in - nick.lowercased().hasPrefix(partial) - } - - // UI will update automatically if !suggestions.isEmpty { - // Only update if suggestions changed - if autocompleteSuggestions != suggestions { - autocompleteSuggestions = suggestions - } - if !showAutocomplete { - showAutocomplete = true - } - if autocompleteRange != match.range(at: 0) { - autocompleteRange = match.range(at: 0) - } + autocompleteSuggestions = suggestions + autocompleteRange = range + showAutocomplete = true selectedAutocompleteIndex = 0 } else { - if showAutocomplete { - showAutocomplete = false - autocompleteSuggestions = [] - autocompleteRange = nil - selectedAutocompleteIndex = 0 - } + autocompleteSuggestions = [] + autocompleteRange = nil + showAutocomplete = false + selectedAutocompleteIndex = 0 } } func completeNickname(_ nickname: String, in text: inout String) -> Int { guard let range = autocompleteRange else { return text.count } - // Replace the @partial with @nickname - let nsText = text as NSString - let newText = nsText.replacingCharacters(in: range, with: "@\(nickname) ") - text = newText + text = autocompleteService.applySuggestion(nickname, to: text, range: range) // Hide autocomplete showAutocomplete = false @@ -1632,8 +1614,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { autocompleteRange = nil selectedAutocompleteIndex = 0 - // Return new cursor position (after the space) - return range.location + nickname.count + 2 + // Return new cursor position + return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2) } // MARK: - Message Formatting @@ -1726,17 +1708,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { var result = AttributedString() let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - let secondaryColor = primaryColor.opacity(0.7) - - // Timestamp - let timestamp = AttributedString("[\(formatTimestamp(message.timestamp))] ") - var timestampStyle = AttributeContainer() - timestampStyle.foregroundColor = message.sender == "system" ? Color.gray : secondaryColor - timestampStyle.font = .system(size: 12, design: .monospaced) - result.append(timestamp.mergingAttributes(timestampStyle)) if message.sender != "system" { - // Sender + // Sender (at the beginning) let sender = AttributedString("<@\(message.sender)> ") var senderStyle = AttributeContainer() @@ -1825,6 +1799,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } result.append(AttributedString(remainingText).mergingAttributes(remainingStyle)) } + + // Add timestamp at the end (smaller, light grey) + let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]") + var timestampStyle = AttributeContainer() + timestampStyle.foregroundColor = Color.gray.opacity(0.7) + timestampStyle.font = .system(size: 10, design: .monospaced) + result.append(timestamp.mergingAttributes(timestampStyle)) } else { // System message var contentStyle = AttributeContainer() @@ -1832,6 +1813,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let content = AttributedString("* \(message.content) *") contentStyle.font = .system(size: 12, design: .monospaced).italic() result.append(content.mergingAttributes(contentStyle)) + + // Add timestamp at the end for system messages too + let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]") + var timestampStyle = AttributeContainer() + timestampStyle.foregroundColor = Color.gray.opacity(0.5) + timestampStyle.font = .system(size: 10, design: .monospaced) + result.append(timestamp.mergingAttributes(timestampStyle)) } // Cache the formatted text @@ -1845,13 +1833,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let isDark = colorScheme == .dark let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - let secondaryColor = primaryColor.opacity(0.7) - - let timestamp = AttributedString("[\(formatTimestamp(message.timestamp))] ") - var timestampStyle = AttributeContainer() - timestampStyle.foregroundColor = message.sender == "system" ? Color.gray : secondaryColor - timestampStyle.font = .system(size: 12, design: .monospaced) - result.append(timestamp.mergingAttributes(timestampStyle)) if message.sender == "system" { let content = AttributedString("* \(message.content) *") @@ -1859,8 +1840,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate { contentStyle.foregroundColor = Color.gray contentStyle.font = .system(size: 12, design: .monospaced).italic() result.append(content.mergingAttributes(contentStyle)) + + // Add timestamp at the end for system messages + let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]") + var timestampStyle = AttributeContainer() + timestampStyle.foregroundColor = Color.gray.opacity(0.5) + timestampStyle.font = .system(size: 10, design: .monospaced) + result.append(timestamp.mergingAttributes(timestampStyle)) } else { - let sender = AttributedString("<\(message.sender)> ") + let sender = AttributedString("<@\(message.sender)> ") var senderStyle = AttributeContainer() // Use consistent color for all senders @@ -1918,10 +1906,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if message.isRelay, let originalSender = message.originalSender { let relay = AttributedString(" (via \(originalSender))") var relayStyle = AttributeContainer() - relayStyle.foregroundColor = secondaryColor + relayStyle.foregroundColor = primaryColor.opacity(0.7) relayStyle.font = .system(size: 11, design: .monospaced) result.append(relay.mergingAttributes(relayStyle)) } + + // Add timestamp at the end (smaller, light grey) + let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]") + var timestampStyle = AttributeContainer() + timestampStyle.foregroundColor = Color.gray.opacity(0.7) + timestampStyle.font = .system(size: 10, design: .monospaced) + result.append(timestamp.mergingAttributes(timestampStyle)) } return result @@ -1929,12 +1924,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Noise Protocol Support + @MainActor func updateEncryptionStatusForPeers() { for peerID in connectedPeers { updateEncryptionStatusForPeer(peerID) } } + @MainActor func updateEncryptionStatusForPeer(_ peerID: String) { let noiseService = meshService.getNoiseService() @@ -1960,6 +1957,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // UI will update automatically via @Published properties } + @MainActor func getEncryptionStatus(for peerID: String) -> EncryptionStatus { // Check cache first if let cachedStatus = encryptionStatusCache[peerID] { @@ -1973,7 +1971,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let hasEverEstablishedSession = getFingerprint(for: peerID) != nil let sessionState = meshService.getNoiseSessionState(for: peerID) - let storedStatus = peerEncryptionStatus[peerID] let status: EncryptionStatus @@ -2038,10 +2035,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Cache the result encryptionStatusCache[peerID] = status - // Only log occasionally to avoid spam - if Int.random(in: 0..<100) == 0 { - SecureLogger.log("getEncryptionStatus for \(peerID): sessionState=\(sessionState), stored=\(String(describing: storedStatus)), hasEverEstablished=\(hasEverEstablishedSession), final=\(status)", category: SecureLogger.security, level: .debug) - } + // Encryption status determined: \(status) return status } @@ -2066,10 +2060,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } private func trimPrivateChatMessagesIfNeeded(for peerID: String) { - if let count = privateChats[peerID]?.count, count > maxMessages { - let removeCount = count - maxMessages - privateChats[peerID]?.removeFirst(removeCount) - } + // Handled by PrivateChatManager } // MARK: - Message Management @@ -2084,19 +2075,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } private func addPrivateMessage(_ message: BitchatMessage, for peerID: String) { - if privateChats[peerID] == nil { - privateChats[peerID] = [] - } - - // Check for duplicates - guard !(privateChats[peerID]?.contains(where: { $0.id == message.id }) ?? false) else { return } - - privateChats[peerID]?.append(message) - privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } - trimPrivateChatMessagesIfNeeded(for: peerID) + // Deprecated - messages are now added directly in didReceiveMessage to avoid double processing } // Update encryption status in appropriate places, not during view updates + @MainActor private func updateEncryptionStatus(for peerID: String) { let noiseService = meshService.getNoiseService() @@ -2135,7 +2118,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { return peerIndex[peerID] } + @MainActor func getFingerprint(for peerID: String) -> String? { + return unifiedPeerService.getFingerprint(for: peerID) + } + + private func getFingerprint_old(for peerID: String) -> String? { // Remove debug logging to prevent console spam during view updates // First try to get fingerprint from mesh service's peer ID rotation mapping @@ -2143,7 +2131,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { return fingerprint } - // Fallback to noise service (direct Noise session fingerprint) + // Check noise service (direct Noise session fingerprint) if let fingerprint = meshService.getNoiseService().getPeerFingerprint(peerID) { return fingerprint } @@ -2157,6 +2145,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } // Helper to resolve nickname for a peer ID through various sources + @MainActor func resolveNickname(for peerID: String) -> String { // Guard against empty or very short peer IDs guard !peerID.isEmpty else { @@ -2189,7 +2178,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } - // Fallback to anonymous with shortened peer ID + // Use anonymous with shortened peer ID // Ensure we have at least 4 characters for the prefix let prefixLength = min(4, peerID.count) let prefix = String(peerID.prefix(prefixLength)) @@ -2206,6 +2195,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { return fingerprint } + @MainActor func verifyFingerprint(for peerID: String) { guard let fingerprint = getFingerprint(for: peerID) else { return } @@ -2232,15 +2222,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate { DispatchQueue.main.async { guard let self = self else { return } - SecureLogger.log("ChatViewModel: Peer authenticated - \(peerID), fingerprint: \(fingerprint)", category: SecureLogger.security, level: .info) + SecureLogger.log("🔐 Authenticated: \(peerID)", category: SecureLogger.security, level: .info) // Update encryption status if self.verifiedFingerprints.contains(fingerprint) { self.peerEncryptionStatus[peerID] = .noiseVerified - SecureLogger.log("ChatViewModel: Setting encryption status to noiseVerified for \(peerID)", category: SecureLogger.security, level: .info) + // Encryption: noiseVerified } else { self.peerEncryptionStatus[peerID] = .noiseSecured - SecureLogger.log("ChatViewModel: Setting encryption status to noiseSecured for \(peerID)", category: SecureLogger.security, level: .info) + // Encryption: noiseSecured } // Invalidate cache when encryption status changes @@ -2259,9 +2249,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Invalidate cache when encryption status changes self.invalidateEncryptionCache(for: peerID) - - // Schedule UI update - // UI will update automatically } } } @@ -2275,754 +2262,84 @@ class ChatViewModel: ObservableObject, BitchatDelegate { /// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help @MainActor private func handleCommand(_ command: String) { - let parts = command.split(separator: " ") - guard let cmd = parts.first else { return } + let result = commandProcessor.process(command) - switch cmd { - case "/m", "/msg": - if parts.count > 1 { - let targetName = String(parts[1]) - // Remove @ if present - let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName - - // Find peer ID for this nickname - if let peerID = getPeerIDForNickname(nickname) { - startPrivateChat(with: peerID) - - // If there's a message after the nickname, send it - if parts.count > 2 { - let messageContent = parts[2...].joined(separator: " ") - sendPrivateMessage(messageContent, to: peerID) - } else { - addSystemMessage("started private chat with \(nickname)") - } - } else { - addSystemMessage("user '\(nickname)' not found. they may be offline or using a different nickname.") - } - } else { - addSystemMessage("usage: /m @nickname [message] or /m nickname [message]") + switch result { + case .success(let message): + if let msg = message { + addSystemMessage(msg) } - case "/w": - let peerNicknames = meshService.getPeerNicknames() - if connectedPeers.isEmpty { - addSystemMessage("no one else is online right now.") - } else { - let onlineList = connectedPeers.compactMap { peerID in - peerNicknames[peerID] - }.sorted().joined(separator: ", ") - - addSystemMessage("online users: \(onlineList)") - } - case "/clear": - // Clear messages based on current context - if let peerID = selectedPrivateChatPeer { - // Clear private chat - privateChats[peerID]?.removeAll() - } else { - // Clear main messages - messages.removeAll() - } - case "/hug": - if parts.count > 1 { - let targetName = String(parts[1]) - // Remove @ if present - let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName - - // Check if target exists in connected peers - if let targetPeerID = getPeerIDForNickname(nickname) { - // Create hug message - let hugMessage = BitchatMessage( - sender: "system", - content: "🫂 \(self.nickname) hugs \(nickname)", - timestamp: Date(), - isRelay: false, - isPrivate: false, - recipientNickname: nickname, - senderPeerID: meshService.myPeerID - ) - - // Send as a regular message but it will be displayed as system message due to content - let hugContent = "* 🫂 \(self.nickname) hugs \(nickname) *" - if selectedPrivateChatPeer != nil { - // In private chat, send as private message - if let peerNickname = meshService.getPeerNicknames()[targetPeerID] { - meshService.sendPrivateMessage("* 🫂 \(self.nickname) hugs you *", to: targetPeerID, recipientNickname: peerNickname) - } - } else { - // In public chat - meshService.sendMessage(hugContent) - messages.append(hugMessage) - } - } else { - let errorMessage = BitchatMessage( - sender: "system", - content: "cannot hug \(nickname): user not found.", - timestamp: Date(), - isRelay: false - ) - messages.append(errorMessage) - } - } else { - let usageMessage = BitchatMessage( - sender: "system", - content: "usage: /hug ", - timestamp: Date(), - isRelay: false - ) - messages.append(usageMessage) - } - - case "/slap": - if parts.count > 1 { - let targetName = String(parts[1]) - // Remove @ if present - let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName - - // Check if target exists in connected peers - if let targetPeerID = getPeerIDForNickname(nickname) { - // Create slap message - let slapMessage = BitchatMessage( - sender: "system", - content: "🐟 \(self.nickname) slaps \(nickname) around a bit with a large trout", - timestamp: Date(), - isRelay: false, - isPrivate: false, - recipientNickname: nickname, - senderPeerID: meshService.myPeerID - ) - - // Send as a regular message but it will be displayed as system message due to content - let slapContent = "* 🐟 \(self.nickname) slaps \(nickname) around a bit with a large trout *" - if selectedPrivateChatPeer != nil { - // In private chat, send as private message - if let peerNickname = meshService.getPeerNicknames()[targetPeerID] { - meshService.sendPrivateMessage("* 🐟 \(self.nickname) slaps you around a bit with a large trout *", to: targetPeerID, recipientNickname: peerNickname) - } - } else { - // In public chat - meshService.sendMessage(slapContent) - messages.append(slapMessage) - } - } else { - let errorMessage = BitchatMessage( - sender: "system", - content: "cannot slap \(nickname): user not found.", - timestamp: Date(), - isRelay: false - ) - messages.append(errorMessage) - } - } else { - let usageMessage = BitchatMessage( - sender: "system", - content: "usage: /slap ", - timestamp: Date(), - isRelay: false - ) - messages.append(usageMessage) - } - - case "/block": - if parts.count > 1 { - let targetName = String(parts[1]) - // Remove @ if present - let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName - - // Find peer ID for this nickname - if let peerID = getPeerIDForNickname(nickname) { - // Get fingerprint for persistent blocking - if let fingerprintStr = meshService.getPeerFingerprint(peerID) { - - if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprintStr) { - addSystemMessage("\(nickname) is already blocked.") - } else { - // Update or create social identity with blocked status - if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprintStr) { - identity.isBlocked = true - identity.isFavorite = false // Remove from favorites if blocked - SecureIdentityStateManager.shared.updateSocialIdentity(identity) - } else { - let blockedIdentity = SocialIdentity( - fingerprint: fingerprintStr, - localPetname: nil, - claimedNickname: nickname, - trustLevel: .unknown, - isFavorite: false, - isBlocked: true, - notes: nil - ) - SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity) - } - - // Update local sets for UI - blockedUsers.insert(fingerprintStr) - favoritePeers.remove(fingerprintStr) - - addSystemMessage("blocked \(nickname). you will no longer receive messages from them.") - } - } else { - addSystemMessage("cannot block \(nickname): unable to verify identity.") - } - } else { - addSystemMessage("cannot block \(nickname): user not found.") - } - } else { - // List blocked users - if blockedUsers.isEmpty { - addSystemMessage("no blocked peers.") - } else { - // Find nicknames for blocked users - var blockedNicknames: [String] = [] - for (peerID, _) in meshService.getPeerNicknames() { - if let fingerprintStr = meshService.getPeerFingerprint(peerID) { - if blockedUsers.contains(fingerprintStr) { - if let nickname = meshService.getPeerNicknames()[peerID] { - blockedNicknames.append(nickname) - } - } - } - } - - let blockedList = blockedNicknames.isEmpty ? "blocked peers (not currently online)" : blockedNicknames.sorted().joined(separator: ", ") - addSystemMessage("blocked peers: \(blockedList)") - } - } - - case "/unblock": - if parts.count > 1 { - let targetName = String(parts[1]) - // Remove @ if present - let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName - - // Find peer ID for this nickname - if let peerID = getPeerIDForNickname(nickname) { - // Get fingerprint - if let fingerprintStr = meshService.getPeerFingerprint(peerID) { - - if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprintStr) { - // Update social identity to unblock - SecureIdentityStateManager.shared.setBlocked(fingerprintStr, isBlocked: false) - - // Update local set for UI - blockedUsers.remove(fingerprintStr) - - addSystemMessage("unblocked \(nickname).") - } else { - addSystemMessage("\(nickname) is not blocked.") - } - } else { - addSystemMessage("cannot unblock \(nickname): unable to verify identity.") - } - } else { - addSystemMessage("cannot unblock \(nickname): user not found.") - } - } else { - addSystemMessage("usage: /unblock ") - } - - case "/fav": - if parts.count > 1 { - let targetName = String(parts[1]) - // Remove @ if present - let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName - - // Find peer ID for this nickname - if let peerID = getPeerIDForNickname(nickname) { - // Add to favorites using the Nostr integration - if let noisePublicKey = Data(hexString: peerID) { - // Get or set Nostr public key - let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: noisePublicKey, - peerNostrPublicKey: existingFavorite?.peerNostrPublicKey, - peerNickname: nickname - ) - - // Toggle favorite in identity manager for UI - toggleFavorite(peerID: peerID) - - // Send favorite notification - Task { [weak self] in - try? await self?.messageRouter?.sendFavoriteNotification(to: noisePublicKey, isFavorite: true) - } - - addSystemMessage("added \(nickname) to favorites.") - } - } else { - addSystemMessage("can't find peer: \(nickname)") - } - } else { - addSystemMessage("usage: /fav ") - } - - case "/unfav": - if parts.count > 1 { - let targetName = String(parts[1]) - // Remove @ if present - let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName - - // Find peer ID for this nickname - if let peerID = getPeerIDForNickname(nickname) { - // Remove from favorites - if let noisePublicKey = Data(hexString: peerID) { - FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) - - // Toggle favorite in identity manager for UI - toggleFavorite(peerID: peerID) - - // Send unfavorite notification - Task { [weak self] in - try? await self?.messageRouter?.sendFavoriteNotification(to: noisePublicKey, isFavorite: false) - } - - addSystemMessage("removed \(nickname) from favorites.") - } - } else { - addSystemMessage("can't find peer: \(nickname)") - } - } else { - addSystemMessage("usage: /unfav ") - } - - case "/testnostr": - addSystemMessage("testing nostr relay connectivity...") - - Task { @MainActor in - if let relayManager = self.nostrRelayManager { - // Simple connectivity test - relayManager.connect() - - // Wait a moment for connections - try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds - - let statusMessage = if relayManager.isConnected { - "nostr relays connected successfully!" - } else { - "failed to connect to nostr relays - check console for details" - } - - let completeMessage = BitchatMessage( - sender: "system", - content: statusMessage, - timestamp: Date(), - isRelay: false - ) - self.messages.append(completeMessage) - } else { - let errorMessage = BitchatMessage( - sender: "system", - content: "nostr relay manager not initialized", - timestamp: Date(), - isRelay: false - ) - self.messages.append(errorMessage) - } - } - - default: - // Unknown command - addSystemMessage("unknown command: \(cmd).") + case .error(let message): + addSystemMessage(message) + case .handled: + // Command was handled, no message needed + break } } // MARK: - Message Reception - func handleHandshakeRequest(from peerID: String, nickname: String, pendingCount: UInt8) { - // Create a notification message - let notificationMessage = BitchatMessage( - sender: "system", - content: "📨 \(nickname) wants to send you \(pendingCount) message\(pendingCount == 1 ? "" : "s"). Open the conversation to receive.", - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: "system", - mentions: nil - ) - - // Add to messages - messages.append(notificationMessage) - trimMessagesIfNeeded() - - // Show system notification - if let fingerprint = getFingerprint(for: peerID) { - let isFavorite = favoritePeers.contains(fingerprint) - if isFavorite { - // Send favorite notification - NotificationService.shared.sendPrivateMessageNotification( - from: nickname, - message: "\(pendingCount) message\(pendingCount == 1 ? "" : "s") pending", - peerID: peerID - ) - } else { - // Send regular notification - NotificationService.shared.sendMentionNotification( - from: nickname, - message: "\(pendingCount) message\(pendingCount == 1 ? "" : "s") pending. Open conversation to receive." - ) - } - } - } - func didReceiveMessage(_ message: BitchatMessage) { - - - // Check if sender is blocked (for both private and public messages) - if let senderPeerID = message.senderPeerID { - if isPeerBlocked(senderPeerID) { - // Silently ignore messages from blocked users - return - } - } else if let peerID = getPeerIDForNickname(message.sender) { - if isPeerBlocked(peerID) { - // Silently ignore messages from blocked users - return - } - } - - if message.isPrivate { - // Handle private message + Task { @MainActor in + // Early validation + guard !isMessageBlocked(message) else { return } + guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.isPrivate else { return } - // Use the senderPeerID from the message if available - let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) - - if let peerID = senderPeerID { - // Message from someone else - - // First check if we need to migrate existing messages from this sender - let senderNickname = message.sender - let currentFingerprint = getFingerprint(for: peerID) - - if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true { - // Check if we have messages under a different peer ID with same fingerprint - var migratedMessages: [BitchatMessage] = [] - var oldPeerIDsToRemove: [String] = [] - - for (oldPeerID, messages) in privateChats { - if oldPeerID != peerID { - // Check fingerprint match first (most reliable) - let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID] - - if let currentFp = currentFingerprint, - let oldFp = oldFingerprint, - currentFp == oldFp { - // Same peer with different ID - migrate all messages - migratedMessages.append(contentsOf: messages) - oldPeerIDsToRemove.append(oldPeerID) - - SecureLogger.log("📦 Migrating \(messages.count) messages from old peer ID \(oldPeerID) to \(peerID) for incoming message (fingerprint match)", - category: SecureLogger.session, level: .info) - } else if currentFingerprint == nil || oldFingerprint == nil { - // Fallback: Check if this chat contains messages with this sender by nickname - let isRelevantChat = messages.contains { msg in - (msg.sender == senderNickname && msg.sender != nickname) || - (msg.sender == nickname && msg.recipientNickname == senderNickname) - } - - if isRelevantChat { - migratedMessages.append(contentsOf: messages) - oldPeerIDsToRemove.append(oldPeerID) - - SecureLogger.log("📦 Migrating \(messages.count) messages from old peer ID \(oldPeerID) to \(peerID) for incoming message (nickname match)", - category: SecureLogger.session, level: .warning) - } - } - } - } - - // Remove old peer ID entries - for oldPeerID in oldPeerIDsToRemove { - privateChats.removeValue(forKey: oldPeerID) - unreadPrivateMessages.remove(oldPeerID) - } - - // Initialize with migrated messages - privateChats[peerID] = migratedMessages - trimPrivateChatMessagesIfNeeded(for: peerID) - } - - if privateChats[peerID] == nil { - privateChats[peerID] = [] - } - - // Fix delivery status for incoming messages - var messageToStore = message - if message.sender != nickname { - // This is an incoming message - it should NOT have "sending" status - if messageToStore.deliveryStatus == nil || messageToStore.deliveryStatus == .sending { - // Mark it as delivered since we received it - messageToStore.deliveryStatus = .delivered(to: nickname, at: Date()) - } - } - - // Check if this is an action that should be converted to system message - let isActionMessage = messageToStore.content.hasPrefix("* ") && messageToStore.content.hasSuffix(" *") && - (messageToStore.content.contains("🫂") || messageToStore.content.contains("🐟") || - messageToStore.content.contains("took a screenshot")) - - if isActionMessage { - // Convert to system message - messageToStore = BitchatMessage( - id: messageToStore.id, - sender: "system", - content: String(messageToStore.content.dropFirst(2).dropLast(2)), // Remove * * wrapper - timestamp: messageToStore.timestamp, - isRelay: messageToStore.isRelay, - originalSender: messageToStore.originalSender, - isPrivate: messageToStore.isPrivate, - recipientNickname: messageToStore.recipientNickname, - senderPeerID: messageToStore.senderPeerID, - mentions: messageToStore.mentions, - deliveryStatus: messageToStore.deliveryStatus - ) - } - - // Add private message directly - addPrivateMessage(messageToStore, for: peerID) - - // Debug logging - - // Check if we're in a private chat with this peer's fingerprint - // This handles reconnections with new peer IDs - if let chatFingerprint = selectedPrivateChatFingerprint, - let senderFingerprint = peerIDToPublicKeyFingerprint[peerID], - chatFingerprint == senderFingerprint && selectedPrivateChatPeer != peerID { - // Update our private chat peer to the new ID - selectedPrivateChatPeer = peerID - // Schedule UI update when peer ID changes - // UI will update automatically - } - - // Also check if we need to update the private chat peer for any reason - updatePrivateChatPeerIfNeeded() - - // No special handling needed - messages are added immediately - - // Mark as unread if not currently viewing this chat - if selectedPrivateChatPeer != peerID { - unreadPrivateMessages.insert(peerID) - - } else { - // We're viewing this chat, make sure unread is cleared - unreadPrivateMessages.remove(peerID) - - // Send read receipt immediately since we're viewing the chat - if !sentReadReceipts.contains(message.id) { - let receipt = ReadReceipt( - originalMessageID: message.id, - readerID: meshService.myPeerID, - readerNickname: nickname - ) - // Use the message's senderPeerID if available (for Nostr messages) - let recipientID = message.senderPeerID ?? peerID - - // For backwards compatibility, check if this might be a Nostr message - // by checking if the sender has a Nostr key - let receiptToSend = receipt - let recipientToSend = recipientID - Task { @MainActor in - var originalTransport: String? = nil - if let noiseKey = Data(hexString: recipientToSend), - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - favoriteStatus.peerNostrPublicKey != nil, - self.meshService.getPeerNicknames()[recipientToSend] == nil { - // Peer has Nostr key and is not on mesh, likely received via Nostr - originalTransport = "nostr" - } - - self.sendReadReceipt(receiptToSend, to: recipientToSend, originalTransport: originalTransport) - } - sentReadReceipts.insert(message.id) - } - - // Also check if there are other unread messages from this peer - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.markPrivateMessagesAsRead(from: peerID) - } - } - } else if message.sender == nickname { - // Our own message that was echoed back - ignore it since we already added it locally - } - } else { - // Regular public message (main chat) - - // Check if this is an action that should be converted to system message - let isActionMessage = message.content.hasPrefix("* ") && message.content.hasSuffix(" *") && - (message.content.contains("🫂") || message.content.contains("🐟") || - message.content.contains("took a screenshot")) - - let finalMessage: BitchatMessage - if isActionMessage { - // Convert to system message - finalMessage = BitchatMessage( - sender: "system", - content: String(message.content.dropFirst(2).dropLast(2)), // Remove * * wrapper - timestamp: message.timestamp, - isRelay: message.isRelay, - originalSender: message.originalSender, - isPrivate: false, - recipientNickname: message.recipientNickname, - senderPeerID: message.senderPeerID, - mentions: message.mentions - ) + // Route to appropriate handler + if message.isPrivate { + handlePrivateMessage(message) } else { - finalMessage = message + handlePublicMessage(message) } - // Check if this is our own message being echoed back - if finalMessage.sender != nickname && finalMessage.sender != "system" { - // Skip empty or whitespace-only messages - if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - addMessage(finalMessage) - } - } else if finalMessage.sender != "system" { - // Our own message - check if we already have it (by ID and content) - let messageExists = messages.contains { existingMsg in - // Check by ID first - if existingMsg.id == finalMessage.id { - return true - } - // Check by content and sender with time window (within 1 second) - if existingMsg.content == finalMessage.content && - existingMsg.sender == finalMessage.sender { - let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp)) - return timeDiff < 1.0 - } - return false - } - if !messageExists { - // This is a message we sent from another device or it's missing locally - // Skip empty or whitespace-only messages - if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - addMessage(finalMessage) - } - } - } else { - // System message - check for empty content before adding - if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - addMessage(finalMessage) - } - } + // Post-processing + checkForMentions(message) + sendHapticFeedback(for: message) } - - // Check if we're mentioned - let isMentioned = message.mentions?.contains(nickname) ?? false - - // Send notifications for mentions and private messages when app is in background - if isMentioned && message.sender != nickname { - NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) - } else if message.isPrivate && message.sender != nickname { - // Only send notification if the private chat is not currently open - if selectedPrivateChatPeer != message.senderPeerID { - NotificationService.shared.sendPrivateMessageNotification(from: message.sender, message: message.content, peerID: message.senderPeerID ?? "") - } - } - - #if os(iOS) - // Haptic feedback for iOS only - guard UIApplication.shared.applicationState == .active else { - return - } - // Check if this is a hug message directed at the user - let isHugForMe = message.content.contains("🫂") && - (message.content.contains("hugs \(nickname)") || - message.content.contains("hugs you")) - - // Check if this is a slap message directed at the user - let isSlapForMe = message.content.contains("🐟") && - (message.content.contains("slaps \(nickname) around") || - message.content.contains("slaps you around")) - - if isHugForMe && message.sender != nickname { - // Long warm haptic for hugs - continuous gentle vibration - let impactFeedback = UIImpactFeedbackGenerator(style: .medium) - impactFeedback.prepare() - - // Create a warm, sustained haptic pattern - for i in 0..<8 { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.15) { - impactFeedback.impactOccurred() - } - } - - // Add a final stronger pulse - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { - let strongFeedback = UIImpactFeedbackGenerator(style: .heavy) - strongFeedback.prepare() - strongFeedback.impactOccurred() - } - } else if isSlapForMe && message.sender != nickname { - // Very harsh, fast, strong haptic for slaps - multiple sharp impacts - let impactFeedback = UIImpactFeedbackGenerator(style: .heavy) - impactFeedback.prepare() - - // Rapid-fire heavy impacts to simulate a hard slap - impactFeedback.impactOccurred() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { - impactFeedback.impactOccurred() - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { - impactFeedback.impactOccurred() - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.09) { - impactFeedback.impactOccurred() - } - - // Final extra heavy impact - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - let finalImpact = UIImpactFeedbackGenerator(style: .heavy) - finalImpact.prepare() - finalImpact.impactOccurred() - } - } else if isMentioned && message.sender != nickname { - // Very prominent haptic for @mentions - triple tap with heavy impact - let impactFeedback = UIImpactFeedbackGenerator(style: .heavy) - impactFeedback.prepare() - impactFeedback.impactOccurred() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - impactFeedback.impactOccurred() - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - impactFeedback.impactOccurred() - } - } else if message.isPrivate && message.sender != nickname { - // Heavy haptic for private messages - more pronounced - let impactFeedback = UIImpactFeedbackGenerator(style: .heavy) - impactFeedback.prepare() - impactFeedback.impactOccurred() - - // Double tap for extra emphasis - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - impactFeedback.impactOccurred() - } - } else if message.sender != nickname { - // Light haptic for public messages from others - let impactFeedback = UIImpactFeedbackGenerator(style: .light) - impactFeedback.impactOccurred() - } - #endif } - // MARK: - Peer Connection Events func didConnectToPeer(_ peerID: String) { - isConnected = true + SecureLogger.log("🤝 Peer connected: \(peerID)", category: SecureLogger.session, level: .info) - // Register ephemeral session with identity manager - SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID) + // Handle all main actor work async + Task { @MainActor in + isConnected = true + + // Register ephemeral session with identity manager + SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID) + + // Check if we favorite this peer and resend notification on reconnect + // This ensures Nostr key mapping is maintained across reconnections + if let peer = unifiedPeerService.getPeer(by: peerID), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey), + favoriteStatus.isFavorite { + // Resend favorite notification with our Nostr key after a short delay + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + meshService.sendFavoriteNotification(to: peerID, isFavorite: true) + SecureLogger.log("📤 Resent favorite notification to reconnected peer \(peerID)", + category: SecureLogger.session, level: .debug) + } + + // Force UI refresh + objectWillChange.send() + } // Connection messages removed to reduce chat noise } func didDisconnectFromPeer(_ peerID: String) { + SecureLogger.log("👋 Peer disconnected: \(peerID)", category: SecureLogger.session, level: .info) + // Remove ephemeral session from identity manager SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID) + // Update peer list immediately and force UI refresh + DispatchQueue.main.async { [weak self] in + // UnifiedPeerService updates automatically via subscriptions + self?.objectWillChange.send() + } + // Clear sent read receipts for this peer since they'll need to be resent after reconnection // Only clear receipts for messages from this specific peer if let messages = privateChats[peerID] { @@ -3041,10 +2358,43 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // UI updates must run on the main thread. // The delegate callback is not guaranteed to be on the main thread. DispatchQueue.main.async { - self.connectedPeers = peers + // Update through peer manager + // UnifiedPeerService updates automatically via subscriptions self.isConnected = !peers.isEmpty - self.peerManager?.updatePeers() + // Clean up stale unread peer IDs whenever peer list updates + self.cleanupStaleUnreadPeerIDs() + + // Smart notification logic for "bitchatters nearby" + if !peers.isEmpty { + // Only count mesh peers (actually connected via Bluetooth) + let meshPeers = peers.filter { peerID in + self.meshService.isPeerConnected(peerID) + } + + // Check if we have new mesh peers we haven't seen recently + let currentPeerSet = Set(meshPeers) + let newPeers = currentPeerSet.subtracting(self.recentlySeenPeers) + let timeSinceLastNotification = Date().timeIntervalSince(self.lastNetworkNotificationTime) + + // Send notification if: + // 1. We have mesh peers (not just Nostr-only) + // 2. There are new peers we haven't seen + // 3. Either it's been more than 5 minutes since last notification OR we haven't notified yet + if meshPeers.count > 0 && !newPeers.isEmpty && + (timeSinceLastNotification > 300 || !self.hasNotifiedNetworkAvailable) { + self.hasNotifiedNetworkAvailable = true + self.lastNetworkNotificationTime = Date() + self.recentlySeenPeers = currentPeerSet + NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count) + SecureLogger.log("👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers", + category: SecureLogger.session, level: .info) + } + } else { + // No peers - reset tracking + self.hasNotifiedNetworkAvailable = false + self.recentlySeenPeers.removeAll() + } // Register ephemeral sessions for all connected peers for peerID in peers { @@ -3072,6 +2422,75 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Helper Methods + /// Clean up stale unread peer IDs that no longer exist in the peer list + @MainActor + private func cleanupStaleUnreadPeerIDs() { + let currentPeerIDs = Set(unifiedPeerService.peers.map { $0.id }) + let staleIDs = unreadPrivateMessages.subtracting(currentPeerIDs) + + if !staleIDs.isEmpty { + var idsToRemove: [String] = [] + for staleID in staleIDs { + // Don't remove temporary Nostr peer IDs that have messages + if staleID.hasPrefix("nostr_") { + // Check if we have messages from this temporary peer + if let messages = privateChats[staleID], !messages.isEmpty { + // Keep this ID - it has messages + continue + } + } + + // Don't remove stable Noise key hexes (64 char hex strings) that have messages + // These are used for Nostr messages when peer is offline + if staleID.count == 64, staleID.allSatisfy({ $0.isHexDigit }) { + if let messages = privateChats[staleID], !messages.isEmpty { + // Keep this ID - it's a stable key with messages + continue + } + } + + // Remove this stale ID + idsToRemove.append(staleID) + unreadPrivateMessages.remove(staleID) + } + + if !idsToRemove.isEmpty { + SecureLogger.log("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", + category: SecureLogger.session, level: .debug) + } + } + + // Also clean up old sentReadReceipts to prevent unlimited growth + // Keep only receipts from messages we still have + cleanupOldReadReceipts() + } + + private func cleanupOldReadReceipts() { + // Skip cleanup during startup phase or if privateChats is empty + // This prevents removing valid receipts before messages are loaded + if isStartupPhase || privateChats.isEmpty { + return + } + + // Build set of all message IDs we still have + var validMessageIDs = Set() + for (_, messages) in privateChats { + for message in messages { + validMessageIDs.insert(message.id) + } + } + + // Remove receipts for messages we no longer have + let oldCount = sentReadReceipts.count + sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) + + let removedCount = oldCount - sentReadReceipts.count + if removedCount > 0 { + SecureLogger.log("🧹 Cleaned up \(removedCount) old read receipts", + category: SecureLogger.session, level: .debug) + } + } + private func parseMentions(from content: String) -> [String] { let pattern = "@([\\p{L}0-9_]+)" let regex = try? NSRegularExpression(pattern: pattern, options: []) @@ -3121,7 +2540,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { ) // Update peer list to reflect the change - peerManager?.updatePeers() + // UnifiedPeerService updates automatically via subscriptions } func isFavorite(fingerprint: String) -> Bool { @@ -3130,18 +2549,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Delivery Tracking - func didReceiveDeliveryAck(_ ack: DeliveryAck) { - // Find the message and update its delivery status - updateMessageDeliveryStatus(ack.originalMessageID, status: .delivered(to: ack.recipientNickname, at: ack.timestamp)) - } - func didReceiveReadReceipt(_ receipt: ReadReceipt) { // Find the message and update its read status updateMessageDeliveryStatus(receipt.originalMessageID, status: .read(by: receipt.readerNickname, at: receipt.timestamp)) - - // Clear delivery tracking since the message has been read - // This prevents the timeout from marking it as failed - DeliveryTracker.shared.clearDeliveryStatus(for: receipt.originalMessageID) } func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { @@ -3174,22 +2584,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } // Update in private chats - var updatedPrivateChats = privateChats - for (peerID, chatMessages) in updatedPrivateChats { - if let index = chatMessages.firstIndex(where: { $0.id == messageID }) { - let currentStatus = chatMessages[index].deliveryStatus - if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) { - chatMessages[index].deliveryStatus = status - updatedPrivateChats[peerID] = chatMessages - } - } + for (peerID, chatMessages) in privateChats { + guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue } + + let currentStatus = chatMessages[index].deliveryStatus + guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue } + + // Update delivery status directly (BitchatMessage is a class/reference type) + privateChats[peerID]?[index].deliveryStatus = status } - // Force complete reassignment to trigger SwiftUI update + // Trigger UI update for delivery status change DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.privateChats = updatedPrivateChats - // UI will update automatically + self?.objectWillChange.send() } } @@ -3205,4 +2612,1078 @@ class ChatViewModel: ObservableObject, BitchatDelegate { messages.append(systemMessage) } + // MARK: - Simplified Nostr Integration (Inlined from MessageRouter) + + @MainActor + private func sendViaNostr(_ content: String, to recipientNostrPubkey: 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 + } + } + + // Include sender nickname in message format for better identification + let structuredContent = "MSG:\(messageId):\(nickname):\(content)" + + guard let event = try? NostrProtocol.createPrivateMessage( + content: structuredContent, + 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: .info) + + // 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) { + 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 + } + } + + // Create ACK message format + let ackContent = "ACK:\(type):\(messageId)" + + guard 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) + } + } + + @MainActor + private func setupNostrMessageHandling() { + guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { + SecureLogger.log("⚠️ No Nostr identity available for message handling", category: SecureLogger.session, level: .warning) + return + } + + SecureLogger.log("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", + category: SecureLogger.session, level: .debug) + + // Subscribe to Nostr messages + let filter = NostrFilter.giftWrapsFor( + pubkey: currentIdentity.publicKeyHex, + since: Date().addingTimeInterval(-86400) // Last 24 hours + ) + + nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in + Task { @MainActor in + self?.handleNostrMessage(event) + } + } + } + + @MainActor + private func handleNostrMessage(_ giftWrap: NostrEvent) { + // Simple deduplication + if processedNostrEvents.contains(giftWrap.id) { return } + processedNostrEvents.insert(giftWrap.id) + + // Client-side filtering: ignore messages older than 24 hours + // Add 15 minutes buffer since gift wrap timestamps are randomized ±15 minutes + let messageAge = Date().timeIntervalSince1970 - TimeInterval(giftWrap.created_at) + if messageAge > 87300 { // 24 hours + 15 minutes + return // Ignoring old message + } + + // Processing Nostr message + + guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } + + do { + let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: currentIdentity + ) + + // Log timestamp difference for debugging + // Using rumor timestamp instead of gift wrap timestamp + + // Handle favorite notifications + if content.hasPrefix("FAVORITED") || content.hasPrefix("UNFAVORITED") { + handleFavoriteNotification(content: content, from: senderPubkey) + return + } + + // Handle acknowledgments + if content.hasPrefix("ACK:") { + handleNostrAcknowledgment(content: content, from: senderPubkey) + return + } + + // Handle regular messages + var messageId = UUID().uuidString + var messageContent = content + var extractedNickname: String? = nil + + if content.hasPrefix("MSG:") { + let parts = content.split(separator: ":", maxSplits: 3) + if parts.count >= 4 { + // New format with nickname: MSG:ID:NICKNAME:CONTENT + messageId = String(parts[1]) + extractedNickname = String(parts[2]) + messageContent = String(parts[3]) + } else if parts.count >= 3 { + // Old format without nickname: MSG:ID:CONTENT + messageId = String(parts[1]) + messageContent = String(parts[2]) + } + } + + // Try to find sender's Noise key + let senderNoiseKey = findNoiseKey(for: senderPubkey) + + // If we can't find the Noise key, try to match by nickname from the message content + // This can happen when receiving messages from someone not yet in favorites + if senderNoiseKey == nil { + SecureLogger.log("⚠️ Cannot find Noise key for Nostr sender: \(senderPubkey.prefix(16))..., will try nickname matching", + category: SecureLogger.session, level: .debug) + + // Use extracted nickname if available + handleNostrMessageFromUnknownSender( + messageId: messageId, + content: messageContent, + senderPubkey: senderPubkey, + senderNickname: extractedNickname, + timestamp: Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) + ) + return + } + + // We have the sender's Noise key, proceed normally + guard let actualSenderNoiseKey = senderNoiseKey else { return } + + let senderNoiseKeyHex = actualSenderNoiseKey.hexEncodedString() + let senderNickname = FavoritesPersistenceService.shared.getFavoriteStatus(for: actualSenderNoiseKey)?.peerNickname ?? "Unknown" + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) + + // For Nostr messages, always use the stable Noise key hex as the peer ID + // This ensures messages persist across ephemeral peer ID changes + let targetPeerID = senderNoiseKeyHex + + // Processing message from sender + + // Check if message already exists in local storage + var messageExistsLocally = false + + // Check stable key location + if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true { + messageExistsLocally = true + } + + // Check all ephemeral peer locations + if !messageExistsLocally { + for (_, messages) in privateChats { + if messages.contains(where: { $0.id == messageId }) { + messageExistsLocally = true + break + } + } + } + + // If message already exists locally, skip entirely + if messageExistsLocally { + return // Skipping duplicate message + } + + // Check if we've read this message before (in a previous session) + let wasReadBefore = sentReadReceipts.contains(messageId) + + // Check if message is recent (for notification purposes) + let messageAgeSeconds = Date().timeIntervalSince(messageTimestamp) + let isRecentMessage = messageAgeSeconds < 30 // Message must be less than 30 seconds old + + // Check if we're viewing this chat BEFORE adding the message + var isViewingThisChat = false + if selectedPrivateChatPeer == targetPeerID { + isViewingThisChat = true + } else if let selectedPeer = selectedPrivateChatPeer, + let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer), + selectedPeerData.noisePublicKey == actualSenderNoiseKey { + // We're viewing this chat via ephemeral ID + isViewingThisChat = true + } + + // Determine if this should be marked as unread BEFORE adding to chats + // Only mark as unread if: not previously read AND not currently viewing + // During startup phase, only block OLD messages (>30s) from being marked as unread + // Recent messages should always be marked as unread if not previously read + let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase) + + // Handle based on read status + + // Create message + let message = BitchatMessage( + id: messageId, + sender: senderNickname, + content: messageContent, + timestamp: messageTimestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: nickname, // We are the recipient + senderPeerID: targetPeerID, + mentions: nil, + deliveryStatus: .delivered(to: nickname, at: Date()) + ) + + // Add to private chat (not public messages!) + if privateChats[targetPeerID] == nil { + privateChats[targetPeerID] = [] + } + privateChats[targetPeerID]?.append(message) + trimPrivateChatMessagesIfNeeded(for: targetPeerID) + + // IMPORTANT: Also add to ephemeral peer chat if one is open + // Find any ephemeral peer ID that maps to this stable Noise key + if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in + peer.noisePublicKey == actualSenderNoiseKey + })?.id, ephemeralPeerID != targetPeerID { + // Also add the message to the ephemeral peer's chat + if privateChats[ephemeralPeerID] == nil { + privateChats[ephemeralPeerID] = [] + } + // Check if message doesn't already exist to avoid duplicates + if !privateChats[ephemeralPeerID]!.contains(where: { $0.id == messageId }) { + privateChats[ephemeralPeerID]?.append(message) + trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID) + } + } + + // Send delivery acknowledgment via Nostr only if we haven't read it before + // If we already read it, we've already sent DELIVERED (and probably READ) ACKs + if !wasReadBefore { + sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED") + } + + // Handle notifications and read receipts + if wasReadBefore { + // Message was read in a previous session - don't mark as unread or notify + // Just add to chat history silently + // Not marking previously-read message as unread + } else if isViewingThisChat { + // We're viewing this chat - mark as read and send read receipt + unreadPrivateMessages.remove(targetPeerID) + // Also remove ephemeral ID from unread if present + if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in + peer.noisePublicKey == actualSenderNoiseKey + })?.id { + unreadPrivateMessages.remove(ephemeralPeerID) + } + // Send read acknowledgment + if !sentReadReceipts.contains(messageId) { + sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ") + sentReadReceipts.insert(messageId) + } + } else { + // Not viewing and not previously read + // Use pre-calculated shouldMarkAsUnread to avoid UI flicker + if shouldMarkAsUnread { + unreadPrivateMessages.insert(targetPeerID) + + // Also mark ephemeral peer ID as unread if present + if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in + peer.noisePublicKey == actualSenderNoiseKey + })?.id, ephemeralPeerID != targetPeerID { + unreadPrivateMessages.insert(ephemeralPeerID) + } + + // Only notify if it's a recent message + if isRecentMessage { + NotificationService.shared.sendPrivateMessageNotification( + from: senderNickname, + message: messageContent, + peerID: targetPeerID + ) + } else { + // Not notifying for old message + } + } + } + + objectWillChange.send() + + } catch { + SecureLogger.log("Failed to decrypt Nostr message: \(error)", category: SecureLogger.session, level: .error) + } + } + + @MainActor + private func handleFavoriteNotification(content: String, from nostrPubkey: String) { + let isFavorite = content.hasPrefix("FAVORITED") + guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return } + + FavoritesPersistenceService.shared.updatePeerFavoritedUs( + peerNoisePublicKey: senderNoiseKey, + favorited: isFavorite, + peerNostrPublicKey: nostrPubkey + ) + } + + @MainActor + private func handleNostrAcknowledgment(content: String, from senderPubkey: String) { + // Parse ACK format: "ACK:TYPE:MESSAGE_ID" + let parts = content.split(separator: ":", maxSplits: 2) + guard parts.count >= 3 else { + SecureLogger.log("⚠️ Invalid ACK format: \(content)", category: SecureLogger.session, level: .warning) + return + } + + let ackType = String(parts[1]) + let messageId = String(parts[2]) + + // Check if we've already processed this ACK + let ackKey = "\(messageId):\(ackType):\(senderPubkey)" + if processedNostrAcks.contains(ackKey) { + // Skip duplicate ACK + return + } + processedNostrAcks.insert(ackKey) + + SecureLogger.log("📨 Received \(ackType) ACK for message \(messageId.prefix(16))... from \(senderPubkey.prefix(16))...", + category: SecureLogger.session, level: .debug) + + // Verify the sender has a valid Noise key + guard findNoiseKey(for: senderPubkey) != nil else { + // Cannot find Noise key for ACK sender + return + } + + // Find and update the message status in ALL private chats (both stable and ephemeral) + var messageFound = false + for (chatPeerID, messages) in privateChats { + if let index = messages.firstIndex(where: { $0.id == messageId }) { + // Update delivery status based on ACK type + switch ackType { + case "DELIVERED": + privateChats[chatPeerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date()) + case "READ": + privateChats[chatPeerID]?[index].deliveryStatus = .read(by: "recipient", at: Date()) + default: + SecureLogger.log("⚠️ Unknown ACK type: \(ackType)", category: SecureLogger.session, level: .warning) + } + + messageFound = true + SecureLogger.log("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.prefix(16))...", + category: SecureLogger.session, level: .info) + // Don't break - continue to update in all chats where this message exists + } + } + + if messageFound { + objectWillChange.send() + } else { + SecureLogger.log("⚠️ Could not find message \(messageId) to update status from ACK", + category: SecureLogger.session, level: .warning) + } + } + + @MainActor + private func handleFavoriteNotificationFromMesh(_ content: String, from peerID: String, senderNickname: String) { + // Parse the message format: "[FAVORITED]:npub..." or "[UNFAVORITED]:npub..." + let isFavorite = content.hasPrefix("[FAVORITED]") + let parts = content.split(separator: ":") + + // Extract Nostr public key if included + var nostrPubkey: String? = nil + if parts.count > 1 { + nostrPubkey = String(parts[1]) + SecureLogger.log("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", + category: SecureLogger.session, level: .info) + } + + // Get the noise public key for this peer + // Try both ephemeral ID and if that fails, get from peer service + var noiseKey: Data? = nil + + // First try as hex-encoded Noise key (64 chars) + if peerID.count == 64 { + noiseKey = Data(hexString: peerID) + } + + // If not a hex key, get from peer service (ephemeral ID) + if noiseKey == nil, let peer = unifiedPeerService.getPeer(by: peerID) { + noiseKey = peer.noisePublicKey + } + + guard let finalNoiseKey = noiseKey else { + SecureLogger.log("⚠️ Cannot get Noise key for peer \(peerID)", + category: SecureLogger.session, level: .warning) + return + } + + // Update the favorite relationship + FavoritesPersistenceService.shared.updatePeerFavoritedUs( + peerNoisePublicKey: finalNoiseKey, + favorited: isFavorite, + peerNickname: senderNickname, + peerNostrPublicKey: nostrPubkey + ) + + // If they favorited us and provided their Nostr key, ensure it's stored + if isFavorite && nostrPubkey != nil { + SecureLogger.log("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", + category: SecureLogger.session, level: .info) + } + + // Show system message + let action = isFavorite ? "favorited" : "unfavorited" + addSystemMessage("\(senderNickname) \(action) you") + } + + @MainActor + private func handleNostrMessageFromUnknownSender( + messageId: String, + content: String, + senderPubkey: String, + senderNickname: String? = nil, + timestamp: Date + ) { + // Check if we already have this message in local storage + for (_, messages) in privateChats { + if messages.contains(where: { $0.id == messageId }) { + return // Skipping duplicate message + } + } + + // Check if we've read this message before (in a previous session) + let wasReadBefore = sentReadReceipts.contains(messageId) + + // Try to find sender by checking all known peers for nickname matches + // This is a fallback when we receive Nostr messages from someone not in favorites + + // For now, create a temporary peer ID based on Nostr pubkey + // This allows the message to be displayed even without Noise key mapping + let tempPeerID = "nostr_" + senderPubkey.prefix(16) + + // Check if we're viewing this unknown sender's chat + let isViewingThisChat = selectedPrivateChatPeer == tempPeerID + + // Check if message is recent (less than 30 seconds old) + let messageAgeSeconds = Date().timeIntervalSince(timestamp) + let isRecentMessage = messageAgeSeconds < 30 + + // Determine if we should mark as unread BEFORE adding to chats + // During startup phase, only block OLD messages from being marked as unread + // Recent messages should always be marked as unread if not previously read + let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase) + + // Use provided nickname or try to extract from previous messages + var finalSenderNickname = senderNickname ?? "Unknown" + + // If no nickname provided, check if we have any previous messages from this Nostr key + if senderNickname == nil { + for (_, messages) in privateChats { + if let previousMessage = messages.first(where: { + $0.senderPeerID == tempPeerID + }) { + finalSenderNickname = previousMessage.sender + break + } + } + } + + // Create the message + let message = BitchatMessage( + id: messageId, + sender: finalSenderNickname, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: nickname, + senderPeerID: tempPeerID, + mentions: nil, + deliveryStatus: .delivered(to: nickname, at: Date()) + ) + + // Store in private chats + if privateChats[tempPeerID] == nil { + privateChats[tempPeerID] = [] + } + privateChats[tempPeerID]?.append(message) + trimPrivateChatMessagesIfNeeded(for: tempPeerID) + + // Send delivery acknowledgment only if we haven't read it before + if !wasReadBefore { + sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED") + } + + // Handle based on read status + if wasReadBefore { + // Message was read in a previous session - don't mark as unread or notify + // Not marking previously-read message as unread + } else if isViewingThisChat { + // Viewing this chat - mark as read + if !sentReadReceipts.contains(messageId) { + sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ") + sentReadReceipts.insert(messageId) + } + } else { + // Not viewing and not previously read + // Use pre-calculated shouldMarkAsUnread to avoid UI flicker + if shouldMarkAsUnread { + unreadPrivateMessages.insert(tempPeerID) + + // Only notify if it's a recent message + if isRecentMessage { + NotificationService.shared.sendPrivateMessageNotification( + from: finalSenderNickname, + message: content, + peerID: tempPeerID + ) + } else { + // Not notifying for old message + } + } + // Not notifying for old message + } + + SecureLogger.log("📬 Stored Nostr message from unknown sender \(finalSenderNickname) in temporary peer \(tempPeerID)", + category: SecureLogger.session, level: .info) + } + + @MainActor + private func findNoiseKey(for nostrPubkey: String) -> Data? { + // Convert hex to npub if needed for comparison + let npubToMatch: String + if nostrPubkey.hasPrefix("npub") { + npubToMatch = nostrPubkey + } else { + // Try to convert hex to npub + guard let pubkeyData = Data(hexString: nostrPubkey) else { + SecureLogger.log("⚠️ Invalid hex public key format: \(nostrPubkey.prefix(16))...", + category: SecureLogger.session, level: .warning) + return nil + } + + do { + npubToMatch = try Bech32.encode(hrp: "npub", data: pubkeyData) + } catch { + SecureLogger.log("⚠️ Failed to convert hex to npub: \(error)", + category: SecureLogger.session, level: .warning) + return nil + } + } + + // Search through favorites for matching Nostr pubkey + for (noiseKey, relationship) in FavoritesPersistenceService.shared.favorites { + if let storedNostrKey = relationship.peerNostrPublicKey { + // Compare npub format + if storedNostrKey == npubToMatch { + // SecureLogger.log("✅ Found Noise key for Nostr sender (npub match)", + // category: SecureLogger.session, level: .debug) + return noiseKey + } + + // Also try hex comparison if stored value is hex + if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey { + SecureLogger.log("✅ Found Noise key for Nostr sender (hex match)", + category: SecureLogger.session, level: .debug) + return noiseKey + } + } + } + + SecureLogger.log("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", + category: SecureLogger.session, level: .debug) + return nil + } + + @MainActor + private func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { + // Send favorite notification via Nostr when we don't have ephemeral peer ID + if let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey), + let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey { + + guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } + + let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)" + + if let event = try? NostrProtocol.createPrivateMessage( + content: content, + recipientPubkey: recipientNostrPubkey, + senderIdentity: senderIdentity + ) { + if let relayManager = nostrRelayManager { + relayManager.sendEvent(event) + SecureLogger.log("Sent favorite notification via Nostr", category: SecureLogger.session, level: .debug) + } + } + } + } + + @MainActor + func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { + // Handle both ephemeral peer IDs and Noise key hex strings + var noiseKey: Data? + + // First check if peerID is a hex-encoded Noise key + if let hexKey = Data(hexString: peerID) { + noiseKey = hexKey + } else { + // It's an ephemeral peer ID, get the Noise key from UnifiedPeerService + if let peer = unifiedPeerService.getPeer(by: peerID) { + noiseKey = peer.noisePublicKey + } + } + + // Try mesh first for connected peers + if meshService.isPeerConnected(peerID) { + meshService.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) + SecureLogger.log("📤 Sent favorite notification via BLE to \(peerID)", category: SecureLogger.session, level: .info) + } else if let key = noiseKey, + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: key), + let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey { + // Send via Nostr for offline peers + guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { + SecureLogger.log("❌ No Nostr identity for sending favorite notification", category: SecureLogger.session, level: .error) + return + } + + let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)" + + if let event = try? NostrProtocol.createPrivateMessage( + content: content, + recipientPubkey: recipientNostrPubkey, + senderIdentity: senderIdentity + ) { + if let relayManager = nostrRelayManager { + relayManager.sendEvent(event) + SecureLogger.log("📤 Sent favorite notification via Nostr to \(favoriteStatus.peerNickname)", category: SecureLogger.session, level: .info) + } else { + SecureLogger.log("❌ NostrRelayManager is nil - cannot send favorite notification", category: SecureLogger.session, level: .error) + } + } else { + SecureLogger.log("❌ Failed to create Nostr message for favorite notification", category: SecureLogger.session, level: .error) + } + } else { + SecureLogger.log("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: SecureLogger.session, level: .warning) + } + } + + // MARK: - Message Processing Helpers + + /// Check if a message should be blocked based on sender + @MainActor + private func isMessageBlocked(_ message: BitchatMessage) -> Bool { + if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) { + return isPeerBlocked(peerID) + } + return false + } + + /// Process action messages (hugs, slaps) into system messages + private func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { + let isActionMessage = message.content.hasPrefix("* ") && message.content.hasSuffix(" *") && + (message.content.contains("🫂") || message.content.contains("🐟") || + message.content.contains("took a screenshot")) + + if isActionMessage { + return BitchatMessage( + id: message.id, + sender: "system", + content: String(message.content.dropFirst(2).dropLast(2)), // Remove * * wrapper + timestamp: message.timestamp, + isRelay: message.isRelay, + originalSender: message.originalSender, + isPrivate: message.isPrivate, + recipientNickname: message.recipientNickname, + senderPeerID: message.senderPeerID, + mentions: message.mentions, + deliveryStatus: message.deliveryStatus + ) + } + return message + } + + /// Migrate private chats when peer reconnects with new ID + @MainActor + private func migratePrivateChatsIfNeeded(for peerID: String, senderNickname: String) { + let currentFingerprint = getFingerprint(for: peerID) + + if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true { + var migratedMessages: [BitchatMessage] = [] + var oldPeerIDsToRemove: [String] = [] + + // Only migrate messages from the last 24 hours to prevent old messages from flooding + let cutoffTime = Date().addingTimeInterval(-24 * 60 * 60) + + for (oldPeerID, messages) in privateChats { + if oldPeerID != peerID { + let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID] + + // Filter messages to only recent ones + let recentMessages = messages.filter { $0.timestamp > cutoffTime } + + // Skip if no recent messages + guard !recentMessages.isEmpty else { continue } + + // Check fingerprint match first (most reliable) + if let currentFp = currentFingerprint, + let oldFp = oldFingerprint, + currentFp == oldFp { + migratedMessages.append(contentsOf: recentMessages) + + // Only remove old peer ID if we migrated ALL its messages + if recentMessages.count == messages.count { + oldPeerIDsToRemove.append(oldPeerID) + } else { + // Keep old messages in original location but don't show in UI + SecureLogger.log("📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)", + category: SecureLogger.session, level: .info) + } + + SecureLogger.log("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)", + category: SecureLogger.session, level: .info) + } else if currentFingerprint == nil || oldFingerprint == nil { + // Check if this chat contains messages with this sender by nickname + let isRelevantChat = recentMessages.contains { msg in + (msg.sender == senderNickname && msg.sender != nickname) || + (msg.sender == nickname && msg.recipientNickname == senderNickname) + } + + if isRelevantChat { + migratedMessages.append(contentsOf: recentMessages) + + // Only remove if all messages were migrated + if recentMessages.count == messages.count { + oldPeerIDsToRemove.append(oldPeerID) + } + + SecureLogger.log("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)", + category: SecureLogger.session, level: .warning) + } + } + } + } + + // Remove old peer ID entries + if !oldPeerIDsToRemove.isEmpty { + // Track if we need to update selectedPrivateChatPeer + let needsSelectedUpdate = oldPeerIDsToRemove.contains { selectedPrivateChatPeer == $0 } + + // Directly modify privateChats to minimize UI disruption + for oldPeerID in oldPeerIDsToRemove { + privateChats.removeValue(forKey: oldPeerID) + unreadPrivateMessages.remove(oldPeerID) + } + + // Add or update messages for the new peer ID + if var existingMessages = privateChats[peerID] { + // Merge with existing messages, avoiding duplicates + let existingIds = Set(existingMessages.map { $0.id }) + for msg in migratedMessages where !existingIds.contains(msg.id) { + existingMessages.append(msg) + } + existingMessages.sort { $0.timestamp < $1.timestamp } + privateChats[peerID] = existingMessages + } else { + // Initialize with migrated messages + privateChats[peerID] = migratedMessages + } + trimPrivateChatMessagesIfNeeded(for: peerID) + + // Update selectedPrivateChatPeer if it was pointing to an old ID + if needsSelectedUpdate { + selectedPrivateChatPeer = peerID + SecureLogger.log("📱 Updated selectedPrivateChatPeer from old ID to \(peerID) during migration", + category: SecureLogger.session, level: .info) + } + } + } + } + + /// Handle incoming private message + @MainActor + private func handlePrivateMessage(_ message: BitchatMessage) { + SecureLogger.log("📥 handlePrivateMessage called for message from \(message.sender)", category: SecureLogger.session, level: .info) + let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) + + guard let peerID = senderPeerID else { + SecureLogger.log("⚠️ Could not get peer ID for sender \(message.sender)", category: SecureLogger.session, level: .warning) + return + } + + // Check if this is a favorite/unfavorite notification + if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") { + handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender) + return // Don't store as a regular message + } + + // Migrate chats if needed + migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender) + + // IMPORTANT: Also consolidate messages from stable Noise key if this is an ephemeral peer + // This ensures Nostr messages appear in BLE chats + if peerID.count == 16 { // This is an ephemeral peer ID (8 bytes = 16 hex chars) + if let peer = unifiedPeerService.getPeer(by: peerID) { + let stableKeyHex = peer.noisePublicKey.hexEncodedString() + + // If we have messages stored under the stable key, merge them + if stableKeyHex != peerID, let nostrMessages = privateChats[stableKeyHex], !nostrMessages.isEmpty { + // Merge messages from stable key into ephemeral peer ID storage + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } + + // Add any messages that aren't already in the ephemeral storage + let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? []) + for nostrMessage in nostrMessages { + if !existingMessageIds.contains(nostrMessage.id) { + privateChats[peerID]?.append(nostrMessage) + } + } + + // Sort by timestamp + privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } + + // Clean up the stable key storage to avoid duplication + privateChats.removeValue(forKey: stableKeyHex) + + SecureLogger.log("📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", + category: SecureLogger.session, level: .info) + } + } + } + + // Initialize chat if needed + if privateChats[peerID] == nil { + var chats = privateChats + chats[peerID] = [] + privateChats = chats + } + + // Fix delivery status for incoming messages + var messageToStore = message + if message.sender != nickname { + if messageToStore.deliveryStatus == nil || messageToStore.deliveryStatus == .sending { + messageToStore.deliveryStatus = .delivered(to: nickname, at: Date()) + } + } + + // Process action messages + messageToStore = processActionMessage(messageToStore) + + // Store message + var chats = privateChats + chats[peerID]?.append(messageToStore) + privateChats = chats + trimPrivateChatMessagesIfNeeded(for: peerID) + + // Trigger UI update + objectWillChange.send() + + // Handle fingerprint-based chat updates + if let chatFingerprint = selectedPrivateChatFingerprint, + let senderFingerprint = peerIDToPublicKeyFingerprint[peerID], + chatFingerprint == senderFingerprint && selectedPrivateChatPeer != peerID { + selectedPrivateChatPeer = peerID + } + + updatePrivateChatPeerIfNeeded() + + // Handle notifications and read receipts + // Check if we should send notification + if selectedPrivateChatPeer != peerID { + unreadPrivateMessages.insert(peerID) + + NotificationService.shared.sendPrivateMessageNotification( + from: message.sender, + message: message.content, + peerID: peerID + ) + } else { + // User is viewing this chat - no notification needed + unreadPrivateMessages.remove(peerID) + + // Also clean up any old peer IDs from unread set that no longer exist + // This prevents stale unread indicators + cleanupStaleUnreadPeerIDs() + + // Send read receipt if needed + if !sentReadReceipts.contains(message.id) { + let receipt = ReadReceipt( + originalMessageID: message.id, + readerID: meshService.myPeerID, + readerNickname: nickname + ) + + let recipientID = message.senderPeerID ?? peerID + + Task { @MainActor in + var originalTransport: String? = nil + if let noiseKey = Data(hexString: recipientID), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + favoriteStatus.peerNostrPublicKey != nil, + self.meshService.getPeerNicknames()[recipientID] == nil { + originalTransport = "nostr" + } + + self.sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport) + } + sentReadReceipts.insert(message.id) + } + + // Mark other messages as read + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.markPrivateMessagesAsRead(from: peerID) + } + } + } + + /// Handle incoming public message + private func handlePublicMessage(_ message: BitchatMessage) { + let finalMessage = processActionMessage(message) + + // Check if this is our own message being echoed back + if finalMessage.sender != nickname && finalMessage.sender != "system" { + // Skip empty messages + if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + addMessage(finalMessage) + } + } else if finalMessage.sender != "system" { + // Check for duplicates + let messageExists = messages.contains { existingMsg in + if existingMsg.id == finalMessage.id { + return true + } + if existingMsg.content == finalMessage.content && + existingMsg.sender == finalMessage.sender { + let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp)) + return timeDiff < 1.0 + } + return false + } + + if !messageExists && !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + addMessage(finalMessage) + } + } else { + // System message + if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + addMessage(finalMessage) + } + } + } + + /// Check for mentions and send notifications + private func checkForMentions(_ message: BitchatMessage) { + let isMentioned = message.mentions?.contains(nickname) ?? false + + // Mention check: \(isMentioned ? "mentioned" : "not mentioned") + + if isMentioned && message.sender != nickname { + SecureLogger.log("🔔 Mention from \(message.sender)", + category: SecureLogger.session, level: .info) + NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) + } + } + + /// Send haptic feedback for special messages (iOS only) + private func sendHapticFeedback(for message: BitchatMessage) { + #if os(iOS) + guard UIApplication.shared.applicationState == .active else { return } + + let isHugForMe = message.content.contains("🫂") && + (message.content.contains("hugs \(nickname)") || + message.content.contains("hugs you")) + + let isSlapForMe = message.content.contains("🐟") && + (message.content.contains("slaps \(nickname) around") || + message.content.contains("slaps you around")) + + if isHugForMe && message.sender != nickname { + // Long warm haptic for hugs + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.prepare() + + for i in 0..<8 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.15) { + impactFeedback.impactOccurred() + } + } + } else if isSlapForMe && message.sender != nickname { + // Sharp haptic for slaps + let impactFeedback = UIImpactFeedbackGenerator(style: .heavy) + impactFeedback.prepare() + impactFeedback.impactOccurred() + } + #endif + } + } // End of ChatViewModel class diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index abe33094..8ddc31a1 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -397,7 +397,7 @@ struct ContentView: View { _ = viewModel.completeNickname(suggestion, in: &messageText) }) { HStack { - Text("@\(suggestion)") + Text(suggestion) .font(.system(size: 11, design: .monospaced)) .foregroundColor(textColor) .fontWeight(.medium) @@ -426,10 +426,13 @@ struct ContentView: View { let commandInfo: [(commands: [String], syntax: String?, description: String)] = [ (["/block"], "[nickname]", "block or list blocked peers"), (["/clear"], nil, "clear chat messages"), + (["/fav"], "", "add to favorites"), + (["/help"], nil, "show this help"), (["/hug"], "", "send someone a warm hug"), (["/m", "/msg"], " [message]", "send private message"), (["/slap"], "", "slap someone with a trout"), (["/unblock"], "", "unblock a peer"), + (["/unfav"], "", "remove from favorites"), (["/w"], nil, "see who's online") ] @@ -512,10 +515,13 @@ struct ContentView: View { let commandDescriptions = [ ("/block", "block or list blocked peers"), ("/clear", "clear chat messages"), + ("/fav", "add to favorites"), + ("/help", "show this help"), ("/hug", "send someone a warm hug"), ("/m", "send private message"), ("/slap", "slap someone with a trout"), ("/unblock", "unblock a peer"), + ("/unfav", "remove from favorites"), ("/w", "see who's online") ] @@ -645,7 +651,7 @@ struct ContentView: View { displayName: peer.id == currentMyPeerID ? viewModel.nickname : peer.nickname, isFavorite: peer.favoriteStatus?.isFavorite ?? false, isMe: peer.id == currentMyPeerID, - hasUnreadMessages: viewModel.unreadPrivateMessages.contains(peer.id), + hasUnreadMessages: viewModel.hasUnreadMessages(for: peer.id), encryptionStatus: viewModel.getEncryptionStatus(for: peer.id), connectionState: peer.connectionState, isMutualFavorite: peer.favoriteStatus?.isMutual ?? false @@ -884,7 +890,7 @@ struct ContentView: View { // People counter with unread indicator HStack(spacing: 4) { - if !viewModel.unreadPrivateMessages.isEmpty { + if viewModel.hasAnyUnreadMessages { Image(systemName: "envelope.fill") .font(.system(size: 12)) .foregroundColor(Color.orange) diff --git a/bitchatShareExtension/Info.plist b/bitchatShareExtension/Info.plist index 002fd391..caa4c793 100644 --- a/bitchatShareExtension/Info.plist +++ b/bitchatShareExtension/Info.plist @@ -26,6 +26,8 @@ NSExtensionActivationRule + NSExtensionActivationSupportsImageWithMaxCount + 1 NSExtensionActivationSupportsText NSExtensionActivationSupportsWebURLWithMaxCount diff --git a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift index 171d47b7..aae0d021 100644 --- a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift @@ -16,9 +16,6 @@ final class PrivateChatE2ETests: XCTestCase { var bob: MockBluetoothMeshService! var charlie: MockBluetoothMeshService! - var deliveryTracker: DeliveryTracker! - var retryService: MessageRetryService! - override func setUp() { super.setUp() @@ -27,22 +24,13 @@ final class PrivateChatE2ETests: XCTestCase { bob = createMockService(peerID: TestConstants.testPeerID2, nickname: TestConstants.testNickname2) charlie = createMockService(peerID: TestConstants.testPeerID3, nickname: TestConstants.testNickname3) - // Setup delivery tracking - deliveryTracker = DeliveryTracker.shared - retryService = MessageRetryService.shared - retryService.meshService = alice - - // Clear any existing state - deliveryTracker.clearDeliveryStatus(for: "") - retryService.clearRetryQueue() + // Delivery tracking is now handled internally by SimplifiedBluetoothService } override func tearDown() { alice = nil bob = nil charlie = nil - deliveryTracker = nil - retryService = nil super.tearDown() } @@ -104,227 +92,16 @@ final class PrivateChatE2ETests: XCTestCase { // MARK: - Delivery Acknowledgment Tests - func testDeliveryAckGeneration() { - simulateConnection(alice, bob) - - let messageID = UUID().uuidString - let expectation = XCTestExpectation(description: "Delivery status updated") - - // Monitor delivery status - let cancellable = deliveryTracker.deliveryStatusUpdated.sink { update in - if update.messageID == messageID { - switch update.status { - case .delivered(let recipient, _): - XCTAssertEqual(recipient, TestConstants.testNickname2) - expectation.fulfill() - default: - break - } - } - } - - // Setup Bob to generate ACK - bob.packetDeliveryHandler = { packet in - if let message = BitchatMessage.fromBinaryPayload(packet.payload), - message.isPrivate { - // Generate ACK - if let ack = self.deliveryTracker.generateAck( - for: message, - myPeerID: TestConstants.testPeerID2, - myNickname: TestConstants.testNickname2, - hopCount: 1 - ) { - // Send ACK back - let ackData = ack.encode()! - let ackPacket = TestHelpers.createTestPacket( - type: 0x03, - senderID: TestConstants.testPeerID2, - recipientID: TestConstants.testPeerID1, - payload: ackData - ) - self.alice.simulateIncomingPacket(ackPacket) - } - } - } - - // Setup Alice to process ACK - alice.packetDeliveryHandler = { packet in - if packet.type == 0x03 { - if let ack = DeliveryAck.decode(from: packet.payload) { - self.deliveryTracker.processDeliveryAck(ack) - } - } - } - - // Track the message - let trackedMessage = BitchatMessage( - id: messageID, - sender: TestConstants.testNickname1, - content: TestConstants.testMessage1, - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: TestConstants.testNickname2, - senderPeerID: TestConstants.testPeerID1, - mentions: nil - ) - - deliveryTracker.trackMessage( - trackedMessage, - recipientID: TestConstants.testPeerID2, - recipientNickname: TestConstants.testNickname2 - ) - - // Send the message - alice.sendPrivateMessage( - TestConstants.testMessage1, - to: TestConstants.testPeerID2, - recipientNickname: TestConstants.testNickname2, - messageID: messageID - ) - - wait(for: [expectation], timeout: TestConstants.defaultTimeout) - cancellable.cancel() - } + // NOTE: DeliveryTracker has been removed in SimplifiedBluetoothService. + // Delivery tracking is now handled internally. - func testDeliveryTimeout() { - // Don't connect peers - message should timeout - let messageID = UUID().uuidString - let expectation = XCTestExpectation(description: "Delivery failed due to timeout") - - // Use shared instance (can't create new one due to private init) - let shortTimeoutTracker = DeliveryTracker.shared - - let cancellable = shortTimeoutTracker.deliveryStatusUpdated.sink { update in - if update.messageID == messageID { - switch update.status { - case .failed(let reason): - XCTAssertTrue(reason.contains("not delivered")) - expectation.fulfill() - default: - break - } - } - } - - let trackedMessage = BitchatMessage( - id: messageID, - sender: TestConstants.testNickname1, - content: TestConstants.testMessage1, - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: TestConstants.testNickname2, - senderPeerID: TestConstants.testPeerID1, - mentions: nil - ) - - // Track with short timeout (will use default 30s for private messages) - shortTimeoutTracker.trackMessage( - trackedMessage, - recipientID: TestConstants.testPeerID2, - recipientNickname: TestConstants.testNickname2 - ) - - // Don't actually send - let it timeout - // For testing, we'll manually trigger timeout - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - shortTimeoutTracker.clearDeliveryStatus(for: messageID) - shortTimeoutTracker.deliveryStatusUpdated.send((messageID: messageID, status: .failed(reason: "Message not delivered"))) - } - - wait(for: [expectation], timeout: TestConstants.shortTimeout) - cancellable.cancel() - } // MARK: - Message Retry Tests - func testMessageRetryOnFailure() { - let messageContent = "Retry test message" - let expectation = XCTestExpectation(description: "Message retried") - - var sendCount = 0 - - // Override send to count attempts - alice.messageDeliveryHandler = { message in - if message.content == messageContent { - sendCount += 1 - if sendCount == 2 { // Original + 1 retry - expectation.fulfill() - } - } - } - - // Add to retry queue - retryService.addMessageForRetry( - content: messageContent, - isPrivate: true, - recipientPeerID: TestConstants.testPeerID2, - recipientNickname: TestConstants.testNickname2 - ) - - // Simulate connection after delay to trigger retry - DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { - self.simulateConnection(self.alice, self.bob) - } - - wait(for: [expectation], timeout: TestConstants.defaultTimeout) - XCTAssertGreaterThanOrEqual(sendCount, 1) - } + // NOTE: MessageRetryService has been removed in SimplifiedBluetoothService. + // Retry logic is now handled internally. - func testRetryQueueOrdering() { - // Add multiple messages to retry queue - let messages = [ - (content: "First", timestamp: Date().addingTimeInterval(-10)), - (content: "Second", timestamp: Date().addingTimeInterval(-5)), - (content: "Third", timestamp: Date()) - ] - - for msg in messages { - retryService.addMessageForRetry( - content: msg.content, - isPrivate: true, - recipientPeerID: TestConstants.testPeerID2, - recipientNickname: TestConstants.testNickname2, - originalTimestamp: msg.timestamp - ) - } - - XCTAssertEqual(retryService.getRetryQueueCount(), 3) - - var receivedOrder: [String] = [] - let expectation = XCTestExpectation(description: "Messages received in order") - - bob.messageDeliveryHandler = { message in - receivedOrder.append(message.content) - if receivedOrder.count == 3 { - expectation.fulfill() - } - } - - // Connect to trigger retry - simulateConnection(alice, bob) - - wait(for: [expectation], timeout: TestConstants.defaultTimeout) - - // Verify order maintained - XCTAssertEqual(receivedOrder, ["First", "Second", "Third"]) - } - func testRetryQueueMaxSize() { - // Try to add more than max queue size - for i in 0..<60 { - retryService.addMessageForRetry( - content: "Message \(i)", - recipientPeerID: TestConstants.testPeerID2 - ) - } - - // Should not exceed max size (50) - XCTAssertLessThanOrEqual(retryService.getRetryQueueCount(), 50) - } // MARK: - End-to-End Encryption Tests @@ -491,24 +268,7 @@ final class PrivateChatE2ETests: XCTestCase { // MARK: - Error Handling Tests - func testPrivateMessageToUnknownPeer() { - // Alice not connected to anyone - let expectation = XCTestExpectation(description: "Message added to retry queue") - - retryService.addMessageForRetry( - content: TestConstants.testMessage1, - isPrivate: true, - recipientPeerID: TestConstants.testPeerID2, - recipientNickname: TestConstants.testNickname2 - ) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - XCTAssertEqual(self.retryService.getRetryQueueCount(), 1) - expectation.fulfill() - } - - wait(for: [expectation], timeout: TestConstants.shortTimeout) - } + // NOTE: This test relied on MessageRetryService which has been removed func testDuplicateAckPrevention() { simulateConnection(alice, bob) @@ -537,6 +297,8 @@ final class PrivateChatE2ETests: XCTestCase { ) // Generate multiple ACKs for same message + // NOTE: DeliveryTracker has been removed - this test is no longer applicable + /* for _ in 0..<3 { if let ack = deliveryTracker.generateAck( for: message, @@ -554,6 +316,7 @@ final class PrivateChatE2ETests: XCTestCase { alice.simulateIncomingPacket(ackPacket) } } + */ // Should only generate one ACK DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index 078c8c95..c7dc8767 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -287,7 +287,7 @@ final class IntegrationTests: XCTestCase { // Track identity announcements nodes["Bob"]!.packetDeliveryHandler = { packet in - if packet.type == MessageType.noiseIdentityAnnounce.rawValue { + if packet.type == 0x04 { // noiseIdentityAnnounce was removed bobReceivedIdentityAnnounce = true if aliceReceivedIdentityAnnounce { expectation.fulfill() @@ -296,7 +296,7 @@ final class IntegrationTests: XCTestCase { } nodes["Alice"]!.packetDeliveryHandler = { packet in - if packet.type == MessageType.noiseIdentityAnnounce.rawValue { + if packet.type == 0x04 { // noiseIdentityAnnounce was removed aliceReceivedIdentityAnnounce = true if bobReceivedIdentityAnnounce { expectation.fulfill() @@ -347,7 +347,7 @@ final class IntegrationTests: XCTestCase { let handshakeExpectation = XCTestExpectation(description: "New handshake completed") nodes["Bob"]!.packetDeliveryHandler = { packet in - if packet.type == MessageType.noiseHandshakeInit.rawValue { + if packet.type == 0x05 { // noiseHandshakeInit was removed // Bob initiates new handshake after restart do { let response = try self.noiseManagers["Alice"]!.handleIncomingHandshake( @@ -357,7 +357,7 @@ final class IntegrationTests: XCTestCase { if let resp = response { // Send response back to Bob let responsePacket = TestHelpers.createTestPacket( - type: MessageType.noiseHandshakeResp.rawValue, + type: 0x06, // noiseHandshakeResp was removed payload: resp ) self.nodes["Bob"]!.simulateIncomingPacket(responsePacket) @@ -365,7 +365,7 @@ final class IntegrationTests: XCTestCase { } catch { XCTFail("Handshake handling failed: \(error)") } - } else if packet.type == MessageType.noiseHandshakeResp.rawValue && packet.senderID.hexEncodedString() == TestConstants.testPeerID1 { + } else if packet.type == 0x06 // noiseHandshakeResp was removed && packet.senderID.hexEncodedString() == TestConstants.testPeerID1 { // Final handshake message (message 3 in XX pattern) handshakeExpectation.fulfill() } @@ -552,7 +552,7 @@ final class IntegrationTests: XCTestCase { let nackData = nack.toBinaryData() let nackPacket = TestHelpers.createTestPacket( - type: MessageType.protocolNack.rawValue, + type: 0x08, // protocolNack was removed payload: nackData ) self.nodes["Alice"]!.simulateIncomingPacket(nackPacket) @@ -560,7 +560,7 @@ final class IntegrationTests: XCTestCase { // Bob clears session self.noiseManagers["Bob"]!.removeSession(for: TestConstants.testPeerID1) } - } else if packet.type == MessageType.noiseHandshakeInit.rawValue { + } else if packet.type == 0x05 { // noiseHandshakeInit was removed // Bob receives handshake init from Alice after NACK do { let response = try self.noiseManagers["Bob"]!.handleIncomingHandshake( @@ -569,7 +569,7 @@ final class IntegrationTests: XCTestCase { ) if let resp = response { let responsePacket = TestHelpers.createTestPacket( - type: MessageType.noiseHandshakeResp.rawValue, + type: 0x06, // noiseHandshakeResp was removed payload: resp ) self.nodes["Alice"]!.simulateIncomingPacket(responsePacket) @@ -582,7 +582,7 @@ final class IntegrationTests: XCTestCase { // Setup Alice's handler to clear session on NACK and initiate handshake nodes["Alice"]!.packetDeliveryHandler = { packet in - if packet.type == MessageType.protocolNack.rawValue { + if packet.type == 0x08 { // protocolNack was removed // Alice receives NACK - clear session self.noiseManagers["Alice"]!.removeSession(for: TestConstants.testPeerID2) @@ -590,14 +590,14 @@ final class IntegrationTests: XCTestCase { do { let handshakeInit = try self.noiseManagers["Alice"]!.initiateHandshake(with: TestConstants.testPeerID2) let handshakePacket = TestHelpers.createTestPacket( - type: MessageType.noiseHandshakeInit.rawValue, + type: 0x05, // noiseHandshakeInit was removed payload: handshakeInit ) self.nodes["Bob"]!.simulateIncomingPacket(handshakePacket) } catch { XCTFail("Alice failed to initiate handshake: \(error)") } - } else if packet.type == MessageType.noiseHandshakeResp.rawValue { + } else if packet.type == 0x06 // noiseHandshakeResp was removed { // Complete handshake do { let final = try self.noiseManagers["Alice"]!.handleIncomingHandshake( @@ -606,7 +606,7 @@ final class IntegrationTests: XCTestCase { ) if let finalMsg = final { let finalPacket = TestHelpers.createTestPacket( - type: MessageType.noiseHandshakeResp.rawValue, + type: 0x06, // noiseHandshakeResp was removed payload: finalMsg ) self.nodes["Bob"]!.simulateIncomingPacket(finalPacket) diff --git a/bitchatTests/Mocks/MockBluetoothMeshService.swift b/bitchatTests/Mocks/MockBluetoothMeshService.swift index b2ec7bbf..0d940654 100644 --- a/bitchatTests/Mocks/MockBluetoothMeshService.swift +++ b/bitchatTests/Mocks/MockBluetoothMeshService.swift @@ -7,139 +7,8 @@ // import Foundation -import MultipeerConnectivity +import CoreBluetooth @testable import bitchat -class MockBluetoothMeshService: BluetoothMeshService { - var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = [] - var sentPackets: [BitchatPacket] = [] - var connectedPeers: Set = [] - var messageDeliveryHandler: ((BitchatMessage) -> Void)? - var packetDeliveryHandler: ((BitchatPacket) -> Void)? - - // Override these properties - var mockNickname: String = "MockUser" - - override var myPeerID: String { - didSet { - // Update when changed - } - } - - var nickname: String { - return mockNickname - } - - var peerID: String { - return myPeerID - } - - override init() { - super.init() - self.myPeerID = "MOCK1234" - } - - func simulateConnectedPeer(_ peerID: String) { - connectedPeers.insert(peerID) - delegate?.didConnectToPeer(peerID) - delegate?.didUpdatePeerList(Array(connectedPeers)) - } - - func simulateDisconnectedPeer(_ peerID: String) { - connectedPeers.remove(peerID) - delegate?.didDisconnectFromPeer(peerID) - delegate?.didUpdatePeerList(Array(connectedPeers)) - } - - override func sendMessage(_ content: String, mentions: [String], to room: String? = nil, messageID: String? = nil, timestamp: Date? = nil) { - let message = BitchatMessage( - id: messageID ?? UUID().uuidString, - sender: mockNickname, - content: content, - timestamp: timestamp ?? Date(), - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: myPeerID, - mentions: mentions.isEmpty ? nil : mentions - ) - - if let payload = message.toBinaryPayload() { - let packet = BitchatPacket( - type: 0x01, - senderID: myPeerID.data(using: .utf8)!, - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: 3 - ) - - sentMessages.append((message, packet)) - sentPackets.append(packet) - - // Simulate local echo - DispatchQueue.main.async { [weak self] in - self?.delegate?.didReceiveMessage(message) - } - - // Call delivery handler if set - messageDeliveryHandler?(message) - } - } - - override func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) { - let message = BitchatMessage( - id: messageID ?? UUID().uuidString, - sender: mockNickname, - content: content, - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: recipientNickname, - senderPeerID: myPeerID, - mentions: nil - ) - - if let payload = message.toBinaryPayload() { - let packet = BitchatPacket( - type: 0x01, - senderID: myPeerID.data(using: .utf8)!, - recipientID: recipientPeerID.data(using: .utf8)!, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: 3 - ) - - sentMessages.append((message, packet)) - sentPackets.append(packet) - - // Simulate local echo - DispatchQueue.main.async { [weak self] in - self?.delegate?.didReceiveMessage(message) - } - - // Call delivery handler if set - messageDeliveryHandler?(message) - } - } - - func simulateIncomingMessage(_ message: BitchatMessage) { - delegate?.didReceiveMessage(message) - } - - func simulateIncomingPacket(_ packet: BitchatPacket) { - // Process through the actual handling logic - if let message = BitchatMessage.fromBinaryPayload(packet.payload) { - delegate?.didReceiveMessage(message) - } - packetDeliveryHandler?(packet) - } - - func getConnectedPeers() -> [String] { - return Array(connectedPeers) - } -} \ No newline at end of file +// Compatibility wrapper for old tests - please use MockSimplifiedBluetoothService directly +typealias MockBluetoothMeshService = MockSimplifiedBluetoothService \ No newline at end of file diff --git a/bitchatTests/Mocks/MockSimplifiedBluetoothService.swift b/bitchatTests/Mocks/MockSimplifiedBluetoothService.swift new file mode 100644 index 00000000..8986c0be --- /dev/null +++ b/bitchatTests/Mocks/MockSimplifiedBluetoothService.swift @@ -0,0 +1,227 @@ +// +// MockSimplifiedBluetoothService.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import CoreBluetooth +@testable import bitchat + +// Mock implementation that mimics SimplifiedBluetoothService behavior +class MockSimplifiedBluetoothService: NSObject { + + // MARK: - Properties matching SimplifiedBluetoothService + + weak var delegate: BitchatDelegate? + var myPeerID: String = "MOCK1234" + var myNickname: String = "MockUser" + + // Test-specific properties + var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = [] + var sentPackets: [BitchatPacket] = [] + var connectedPeers: Set = [] + var messageDeliveryHandler: ((BitchatMessage) -> Void)? + var packetDeliveryHandler: ((BitchatPacket) -> Void)? + + // Compatibility properties for old tests + var mockNickname: String { + get { return myNickname } + set { myNickname = newValue } + } + + var nickname: String { + return myNickname + } + + var peerID: String { + return myPeerID + } + + // MARK: - Initialization + + override init() { + super.init() + } + + // MARK: - Methods matching SimplifiedBluetoothService + + func setNickname(_ nickname: String) { + self.myNickname = nickname + } + + func startServices() { + // Mock implementation - do nothing + } + + func stopServices() { + // Mock implementation - do nothing + } + + func isPeerConnected(_ peerID: String) -> Bool { + return connectedPeers.contains(peerID) + } + + func getPeerNicknames() -> [String: String] { + var nicknames: [String: String] = [:] + for peer in connectedPeers { + nicknames[peer] = "MockPeer_\(peer)" + } + return nicknames + } + + func getPeers() -> [String: String] { + return getPeerNicknames() + } + + func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) { + let message = BitchatMessage( + id: messageID ?? UUID().uuidString, + sender: myNickname, + content: content, + timestamp: timestamp ?? Date(), + isRelay: false, + originalSender: nil, + isPrivate: recipientID != nil, + recipientNickname: nil, + senderPeerID: myPeerID, + mentions: mentions.isEmpty ? nil : mentions + ) + + if let payload = message.toBinaryPayload() { + let packet = BitchatPacket( + type: 0x01, + senderID: myPeerID.data(using: .utf8)!, + recipientID: recipientID?.data(using: .utf8), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 3 + ) + + sentMessages.append((message, packet)) + sentPackets.append(packet) + + // Simulate local echo + DispatchQueue.main.async { [weak self] in + self?.delegate?.didReceiveMessage(message) + } + + // Call delivery handler if set + messageDeliveryHandler?(message) + } + } + + func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String) { + let message = BitchatMessage( + id: messageID, + sender: myNickname, + content: content, + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: recipientNickname, + senderPeerID: myPeerID, + mentions: nil + ) + + if let payload = message.toBinaryPayload() { + let packet = BitchatPacket( + type: 0x01, + senderID: myPeerID.data(using: .utf8)!, + recipientID: recipientPeerID.data(using: .utf8)!, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 3 + ) + + sentMessages.append((message, packet)) + sentPackets.append(packet) + + // Simulate local echo + DispatchQueue.main.async { [weak self] in + self?.delegate?.didReceiveMessage(message) + } + + // Call delivery handler if set + messageDeliveryHandler?(message) + } + } + + func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { + // Mock implementation + } + + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { + // Mock implementation + } + + func sendBroadcastAnnounce() { + // Mock implementation + } + + func getPeerFingerprint(_ peerID: String) -> String? { + return nil + } + + func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { + return .none + } + + func triggerHandshake(with peerID: String) { + // Mock implementation + } + + func emergencyDisconnectAll() { + connectedPeers.removeAll() + delegate?.didUpdatePeerList([]) + } + + func getNoiseService() -> NoiseEncryptionService { + return NoiseEncryptionService() + } + + func getFingerprint(for peerID: String) -> String? { + return nil + } + + // MARK: - Test Helper Methods + + func simulateConnectedPeer(_ peerID: String) { + connectedPeers.insert(peerID) + delegate?.didConnectToPeer(peerID) + delegate?.didUpdatePeerList(Array(connectedPeers)) + } + + func simulateDisconnectedPeer(_ peerID: String) { + connectedPeers.remove(peerID) + delegate?.didDisconnectFromPeer(peerID) + delegate?.didUpdatePeerList(Array(connectedPeers)) + } + + func simulateIncomingMessage(_ message: BitchatMessage) { + delegate?.didReceiveMessage(message) + } + + func simulateIncomingPacket(_ packet: BitchatPacket) { + // Process through the actual handling logic + if let message = BitchatMessage.fromBinaryPayload(packet.payload) { + delegate?.didReceiveMessage(message) + } + packetDeliveryHandler?(packet) + } + + func getConnectedPeers() -> [String] { + return Array(connectedPeers) + } + + // MARK: - Compatibility methods for old tests + + func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) { + sendPrivateMessage(content, to: recipientPeerID, recipientNickname: recipientNickname, messageID: messageID ?? UUID().uuidString) + } +} \ No newline at end of file diff --git a/bitchatTests/NostrProtocolTests.swift b/bitchatTests/NostrProtocolTests.swift index 355b11bd..5cb5bc04 100644 --- a/bitchatTests/NostrProtocolTests.swift +++ b/bitchatTests/NostrProtocolTests.swift @@ -13,8 +13,7 @@ final class NostrProtocolTests: XCTestCase { override func setUp() { super.setUp() - // Enable secure logging for tests - SecureLogger.enableLogging() + // SecureLogger is always enabled } func testNIP17MessageRoundTrip() throws { @@ -39,7 +38,7 @@ final class NostrProtocolTests: XCTestCase { print("Gift wrap pubkey: \(giftWrap.pubkey)") // Decrypt the gift wrap - let (decryptedContent, senderPubkey) = try NostrProtocol.decryptPrivateMessage( + let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage( giftWrap: giftWrap, recipientIdentity: recipient ) @@ -48,7 +47,12 @@ final class NostrProtocolTests: XCTestCase { XCTAssertEqual(decryptedContent, originalContent) XCTAssertEqual(senderPubkey, sender.publicKeyHex) - print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey)") + // Verify timestamp is reasonable (within last minute) + let messageDate = Date(timeIntervalSince1970: TimeInterval(timestamp)) + let timeDiff = abs(messageDate.timeIntervalSinceNow) + XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent") + + print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)") } func testGiftWrapUsesUniqueEphemeralKeys() throws { @@ -75,11 +79,11 @@ final class NostrProtocolTests: XCTestCase { print("Message 2 gift wrap pubkey: \(message2.pubkey)") // Both should decrypt successfully - let (content1, _) = try NostrProtocol.decryptPrivateMessage( + let (content1, _, _) = try NostrProtocol.decryptPrivateMessage( giftWrap: message1, recipientIdentity: recipient ) - let (content2, _) = try NostrProtocol.decryptPrivateMessage( + let (content2, _, _) = try NostrProtocol.decryptPrivateMessage( giftWrap: message2, recipientIdentity: recipient ) diff --git a/bitchatTests/SimplifiedBluetoothServiceTests.swift b/bitchatTests/SimplifiedBluetoothServiceTests.swift new file mode 100644 index 00000000..d7013a81 --- /dev/null +++ b/bitchatTests/SimplifiedBluetoothServiceTests.swift @@ -0,0 +1,282 @@ +// +// SimplifiedBluetoothServiceTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import XCTest +import CoreBluetooth +@testable import bitchat + +final class SimplifiedBluetoothServiceTests: XCTestCase { + + var service: MockSimplifiedBluetoothService! + + override func setUp() { + super.setUp() + service = MockSimplifiedBluetoothService() + service.myPeerID = "TEST1234" + service.mockNickname = "TestUser" + } + + override func tearDown() { + service = nil + super.tearDown() + } + + // MARK: - Basic Functionality Tests + + func testServiceInitialization() { + XCTAssertNotNil(service) + XCTAssertEqual(service.myPeerID, "TEST1234") + XCTAssertEqual(service.myNickname, "TestUser") + } + + func testPeerConnection() { + // Test connecting a peer + service.simulateConnectedPeer("PEER5678") + XCTAssertTrue(service.isPeerConnected("PEER5678")) + XCTAssertEqual(service.getConnectedPeers().count, 1) + + // Test disconnecting a peer + service.simulateDisconnectedPeer("PEER5678") + XCTAssertFalse(service.isPeerConnected("PEER5678")) + XCTAssertEqual(service.getConnectedPeers().count, 0) + } + + func testMultiplePeerConnections() { + service.simulateConnectedPeer("PEER1") + service.simulateConnectedPeer("PEER2") + service.simulateConnectedPeer("PEER3") + + XCTAssertEqual(service.getConnectedPeers().count, 3) + XCTAssertTrue(service.isPeerConnected("PEER1")) + XCTAssertTrue(service.isPeerConnected("PEER2")) + XCTAssertTrue(service.isPeerConnected("PEER3")) + + service.simulateDisconnectedPeer("PEER2") + XCTAssertEqual(service.getConnectedPeers().count, 2) + XCTAssertFalse(service.isPeerConnected("PEER2")) + } + + // MARK: - Message Sending Tests + + func testSendPublicMessage() { + let expectation = XCTestExpectation(description: "Message sent") + + service.delegate = MockBitchatDelegate { message in + XCTAssertEqual(message.content, "Hello, world!") + XCTAssertEqual(message.sender, "TestUser") + XCTAssertFalse(message.isPrivate) + expectation.fulfill() + } + + service.sendMessage("Hello, world!") + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(service.sentMessages.count, 1) + } + + func testSendPrivateMessage() { + let expectation = XCTestExpectation(description: "Private message sent") + + service.delegate = MockBitchatDelegate { message in + XCTAssertEqual(message.content, "Secret message") + XCTAssertEqual(message.sender, "TestUser") + XCTAssertTrue(message.isPrivate) + XCTAssertEqual(message.recipientNickname, "Bob") + expectation.fulfill() + } + + service.sendPrivateMessage("Secret message", to: "PEER5678", recipientNickname: "Bob", messageID: "MSG123") + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(service.sentMessages.count, 1) + } + + func testSendMessageWithMentions() { + let expectation = XCTestExpectation(description: "Message with mentions sent") + + service.delegate = MockBitchatDelegate { message in + XCTAssertEqual(message.content, "@alice @bob check this out") + XCTAssertEqual(message.mentions, ["alice", "bob"]) + expectation.fulfill() + } + + service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"]) + + wait(for: [expectation], timeout: 1.0) + } + + // MARK: - Message Reception Tests + + func testSimulateIncomingMessage() { + let expectation = XCTestExpectation(description: "Message received") + + service.delegate = MockBitchatDelegate { message in + XCTAssertEqual(message.content, "Incoming message") + XCTAssertEqual(message.sender, "RemoteUser") + expectation.fulfill() + } + + let incomingMessage = BitchatMessage( + id: "MSG456", + sender: "RemoteUser", + content: "Incoming message", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: "REMOTE123", + mentions: nil + ) + + service.simulateIncomingMessage(incomingMessage) + + wait(for: [expectation], timeout: 1.0) + } + + func testSimulateIncomingPacket() { + let expectation = XCTestExpectation(description: "Packet processed") + + service.delegate = MockBitchatDelegate { message in + XCTAssertEqual(message.content, "Packet message") + expectation.fulfill() + } + + let message = BitchatMessage( + id: "MSG789", + sender: "PacketSender", + content: "Packet message", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: "PACKET123", + mentions: nil + ) + + guard let payload = message.toBinaryPayload() else { + XCTFail("Failed to create binary payload") + return + } + + let packet = BitchatPacket( + type: 0x01, + senderID: "PACKET123".data(using: .utf8)!, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 3 + ) + + service.simulateIncomingPacket(packet) + + wait(for: [expectation], timeout: 1.0) + } + + // MARK: - Peer Nickname Tests + + func testGetPeerNicknames() { + service.simulateConnectedPeer("PEER1") + service.simulateConnectedPeer("PEER2") + + let nicknames = service.getPeerNicknames() + XCTAssertEqual(nicknames.count, 2) + XCTAssertEqual(nicknames["PEER1"], "MockPeer_PEER1") + XCTAssertEqual(nicknames["PEER2"], "MockPeer_PEER2") + } + + // MARK: - Service State Tests + + func testStartStopServices() { + // These are mock implementations, just ensure they don't crash + service.startServices() + service.stopServices() + + // Service should still be functional after start/stop + service.simulateConnectedPeer("PEER999") + XCTAssertTrue(service.isPeerConnected("PEER999")) + } + + // MARK: - Message Delivery Handler Tests + + func testMessageDeliveryHandler() { + let expectation = XCTestExpectation(description: "Delivery handler called") + + service.messageDeliveryHandler = { message in + XCTAssertEqual(message.content, "Test delivery") + expectation.fulfill() + } + + service.sendMessage("Test delivery") + + wait(for: [expectation], timeout: 1.0) + } + + func testPacketDeliveryHandler() { + let expectation = XCTestExpectation(description: "Packet handler called") + + service.packetDeliveryHandler = { packet in + XCTAssertEqual(packet.type, 0x01) + expectation.fulfill() + } + + let message = BitchatMessage( + id: "PKT123", + sender: "TestSender", + content: "Test packet", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: "TEST123", + mentions: nil + ) + + guard let payload = message.toBinaryPayload() else { + XCTFail("Failed to create payload") + return + } + + let packet = BitchatPacket( + type: 0x01, + senderID: "TEST123".data(using: .utf8)!, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 3 + ) + + service.simulateIncomingPacket(packet) + + wait(for: [expectation], timeout: 1.0) + } +} + +// MARK: - Mock Delegate Helper + +private class MockBitchatDelegate: BitchatDelegate { + private let messageHandler: (BitchatMessage) -> Void + + init(_ handler: @escaping (BitchatMessage) -> Void) { + self.messageHandler = handler + } + + func didReceiveMessage(_ message: BitchatMessage) { + messageHandler(message) + } + + func didConnectToPeer(_ peerID: String) {} + func didDisconnectFromPeer(_ peerID: String) {} + func didUpdatePeerList(_ peers: [String]) {} + func isFavorite(fingerprint: String) -> Bool { return false } + func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {} +} diff --git a/bitchatTests/TestUtilities/LegacyTestProtocolTypes.swift b/bitchatTests/TestUtilities/LegacyTestProtocolTypes.swift new file mode 100644 index 00000000..118df3ce --- /dev/null +++ b/bitchatTests/TestUtilities/LegacyTestProtocolTypes.swift @@ -0,0 +1,45 @@ +// +// LegacyTestProtocolTypes.swift +// bitchatTests +// +// Minimal legacy protocol types used only by tests to simulate old flows. +// These are not part of production code anymore. + +import Foundation + +struct ProtocolNack { + let originalPacketID: String + let nackID: String + let senderID: String + let receiverID: String + let packetType: UInt8 + let reason: String + let errorCode: UInt8 + + enum ErrorCode: UInt8 { + case unknown = 0 + case decryptionFailed = 2 + } + + init(originalPacketID: String, senderID: String, receiverID: String, packetType: UInt8, reason: String, errorCode: ErrorCode = .unknown) { + self.originalPacketID = originalPacketID + self.nackID = UUID().uuidString + self.senderID = senderID + self.receiverID = receiverID + self.packetType = packetType + self.reason = reason + self.errorCode = errorCode.rawValue + } + + func toBinaryData() -> Data { + // Tests don't parse the payload; return a compact encoding for completeness + var data = Data() + data.appendUUID(originalPacketID) + data.appendUUID(nackID) + data.append(UInt8(packetType)) + data.append(UInt8(errorCode)) + data.appendString(reason) + return data + } +} +